Compare commits

..

41 Commits

Author SHA1 Message Date
Anthony LC 5bb7ad643a 🔖(minor) release 2.4.0
Added:
- (frontend) synchronize language-choice

Changed:
- Use sentry tags instead of extra scope

Fixed:
- 🐛(frontend) fix collaboration error
2025-03-06 15:59:34 +01:00
Anthony LC 57b8881fc6 📌(frontend) pin blocknote globally
Blocknote does not pinned the version.
We get bumped version instead of the version we want.
We pin the version of blocknote globally to
avoid this issue.
2025-03-06 15:09:17 +01:00
Anthony LC 89ad610ba6 🐛(frontend) fix collaboration error
We upgrade blocknote to 0.23.2-hotfix.0,
it includes a fix with the collaboration.
2025-03-06 15:09:17 +01:00
rvveber 251787b835 🚨(tests) add language related tests; fix getByRole not working in tests
- adds tests and test-utility for solid language switching in tests
- fixes where ...getByRole(menuitem... would not return a valid object
2025-03-05 14:29:24 +01:00
rvveber f95173e096 🐛(frontend) allow left panel to update on language change
- fixes a bug where after language-sync the left panel would remain
  in the same language as before.
2025-03-05 14:29:24 +01:00
rvveber a7944cce80 (app) get language from backend; set browser-detected language if null
- adds useLanguageSynchronizer hook to update the:
  1. frontend-language to the user-preference - if there is one.
  2. user-preference to the (browser-detected) frontend-language - otherwise.
2025-03-05 14:29:24 +01:00
rvveber 7941fc91d5 🐛(frontend) set toolbar-popup to current language
- ensure editor is translated to i18n.resolvedLanguage => en
  as i18n.language could hold more accurate locale => en-GB etc..
2025-03-05 14:29:24 +01:00
rvveber 7fc83a4fcd (frontend) the LanguagePicker now uses config as options
- config endpoint languages are used as available options for LanguagePicker
- updating the language from it, triggers an update on the user via API
2025-03-05 14:29:24 +01:00
rvveber 2bf47b7705 (backend) the LanguagePicker now uses config as options
- config endpoint languages are used as available options for LanguagePicker
- updating the language from it, triggers an update on the user via API
2025-03-05 14:29:24 +01:00
rvveber 23b0214a2a (frontend) add language utility for "locale"
- Adds a helper for working with locales
- More details in their annotations
- Unnecessary, if in the future, the backend uses
  the same locales as the keys in the translations (ISO 639-1)
2025-03-05 14:29:24 +01:00
rvveber f244509de3 (frontend) add API access for 'language' attribute on User model
- allow the language attribute on the user to be updated via API
- add frontend function to update the user language via API
- extend defaults on the test users, to have fixed language in E2E tests
- extend types and variables using the types with the new field
2025-03-05 14:29:24 +01:00
rvveber fda5f8f008 (backend) add API access for 'language' attribute on User model
- allow the language attribute on the user to be updated via API
- add frontend function to update the user language via API
- extend defaults on the test users, to have fixed language in E2E tests
- extend types and variables using the types with the new field
2025-03-05 14:29:24 +01:00
rvveber 9a79b09b07 🔨(backend) make the 'language' attribute on the User model nullable
- allow the language on the user to be unset
- set the default language to be unset
- helps us determine that the user has yet to set a language preference
2025-03-05 14:29:24 +01:00
rvveber b24acd14e2 🔨(frontend) email invitation in invited user's language
- language for invitation emails => language saved on the invited user
- if invited user does not exist yet => language of the sending user
- if for some reason no sending user => system default language
2025-03-05 14:29:24 +01:00
rvveber 1531846115 🔨(backend) email invitation in invited user's language
- language for invitation emails => language saved on the invited user
- if invited user does not exist yet => language of the sending user
- if for some reason no sending user => system default language
2025-03-05 14:29:24 +01:00
Manuel Raynaud ebf6d46e37 ♻️(front) use sentry tags instead of extra scope
To ease filtering issues on sentry, we want to use tags instead of extra
scope. Tags are indexed and searchable, it's not the case with extra
scope. Moreover using setEtra to add additional data is deprecated.
2025-03-05 10:26:23 +01:00
Manuel Raynaud b9b5f86cf4 ️(back) restrict documents to restore using only the queryset
To determine the descendant to restore or not, we were looking building
a complex exclude clause. This can be simplify focusing only on data we
already have without making an extra query to fetch the list of
descendant to exclude.
2025-03-04 18:03:18 +01:00
renovate[bot] 56412b0be5 ⬆️(dependencies) update python dependencies 2025-03-04 14:24:58 +01:00
Anthony LC af052cd06b 🔖(minor) release 2.3.0
Added:
- 💄(frontend) add error pages
- 🔒️ Manage unsafe attachments
- (frontend) Custom block quote with export
- (frontend) add open source section homepage

Changed:
- 🛂(frontend) Restore version visibility
- 📝(doc) minor README.md formatting and wording enhancements
- ♻️Stop setting a default title on doc creation
- ♻️(frontend) misc ui improvements

Fixed:
- 🐛(backend) allow any type of extensions for media download
- ♻️(frontend) improve table pdf rendering
2025-03-04 12:12:57 +01:00
Anthony LC 8927635c5f 💄(frontend) hide Crisp when mobile and modal
The Crisp button was hidding buttons on mobile
when a modal was open. This commit hides the
Crisp button when a modal is open on mobile.
2025-03-04 12:12:57 +01:00
Anthony LC 76bce4313b 🩹(frontend) fine tuning 2.3.0
- improve medium button style when 2 lines
- improve design on Firefox input title
- manage title modal without doc title
- improve redirect when 401
2025-03-04 12:12:57 +01:00
Anthony LC 5ac71bfac1 🐛(service-worker) update sw to create a doc without body
Offline creation of a doc was broken because we
don't add a default title anymore when we create a
doc, leading to POST requests without body.
we need to adapt the service worker to handle this
case.
2025-03-04 12:12:57 +01:00
Anthony LC cb4e148afc ♻️(email) adapt email when no title
Default title is not set when we create a document
anymore. We need to adapt the email to handle
this case.
2025-03-04 12:12:57 +01:00
AntoLC 2d24825be0 🌐(i18n) update translated strings
Update translated files with new translations
2025-03-04 12:12:57 +01:00
Anthony LC 7b1ddc0e05 🛂(backend) remove svg from unsafe
We added content-security-policy on nginx.
It should be safe to allow svg files now.
We remove the svg file from the unsafe
attachments list. We adapt the tests accordingly.
2025-03-03 13:18:40 +01:00
Manuel Raynaud 22a665e535 🔒️(nginx) manage Content-Security-Policy in nginx config
The media route is managed by nginx. On this route we want to add the
Content-Security-Header to forbid fetching any resources.
See : https://content-security-policy.com/
2025-03-03 13:18:40 +01:00
Manuel Raynaud a22bf95bce 🔒️(back) set ContentDisposition on media upload
On the media upload endpoint, we want to set the content-disposition
header. Its value is based on the uploaded file mime-type and if flagged
as unsafe. If the file is not an image or is unsafe then the
contentDisposition is set to attachment to force its download.
Otherwise, we set it to inline.
2025-03-03 13:18:40 +01:00
Anthony LC 3ce1826355 🚚(frontend) toolbar components in BlockNoteToolBar folder
We moved the toolbar components in BlockNoteToolBar
folder.
2025-03-03 13:18:40 +01:00
Anthony LC d099d58f77 🛂(frontend) secure download button
Blocknote download button opens the file in a new
tab, which could be not secure because of XSS attacks.
We replace the download button with a new one that
downloads the file instead of opening it in a new tab.
Some files are flags as unsafe (SVG / js / exe),
for these files we add a confirmation modal before
downloading the file to prevent the user from
downloading a file that could be harmful.
In the future, we could add other security layers
from this model, to analyze the file before
downloading it by example.
2025-03-03 13:18:40 +01:00
Anthony LC ebd49f05a8 🚸(frontend) block click on unsafe image
We want to prevent the user to open unsafe images
in the browser. We blocked the click on the images.
To download them, the user will have to use the
download button.
2025-03-03 13:18:40 +01:00
Anthony LC 315c2c2c43 🐛(frontend) improve authenticated state
It can happen that the user is authenticated
then the token is expired. The authenticated
state should be updated to false in this case.
2025-03-03 13:18:40 +01:00
Anthony LC e442908c50 💄(frontend) improve the design of the alert error
Since the new design implementation,
the alert error was not looking good.
This commit improves the design of the alert error.
2025-03-03 13:18:40 +01:00
Anthony LC 6672292d93 🛂(backend) add unsafe in the attachments filename
The frontend cannot access custom headers of a file,
so we need to add a flag in the filename.
We add the `unsafe` flag in the filename to
indicate that the file is unsafe.

Previous filename: "/{UUID4}.{extension}"
New filename: "/{UUID4}-unsafe.{extension}"
2025-03-03 13:18:40 +01:00
Manuel Raynaud 7dda74421f ♻️(back) extract ancestor deleted_at directly from db in restore method
In the restore method, all the ancestors with a deleted_at date set are
extracted from the database and then the oldest value is extracted using
the min python function. This usage of min can be removed by sorting
directly the deleted_at at the databse level and then fetching the first
one. It's faster and easier to maintain.
2025-03-03 13:05:36 +01:00
Anthony LC 9c25b684e3 (frontend) add open source section homepage
We decided to add the open source section on
the homepage of Docs.
2025-03-03 12:42:18 +01:00
Anthony LC cd5ee3fb7c (frontend) adapt export to quote block
We have a new block type, the quote block.
We have to adapt the export to handle this
new block type.
2025-03-03 12:27:02 +01:00
Anthony LC 942c0f059c 🏗️(frontend) blockMapping refactoring
As made for TablePDF, we separate the block mapping
in separate files. This will allow us to have
a better separation of concerns and to have
a more maintainable codebase.
We improve as well the typing. It will be easier
to add new blocks in the future.
2025-03-03 12:27:02 +01:00
Anthony LC 3acee1e6fa (frontend) create feature doc-export
Create the feature doc-export, it will be
responsible for exporting the document.
2025-03-03 12:27:02 +01:00
Anthony LC 26ea32bd0b 🏷️(frontend) adapt title types
We recently changed the default title behavior.
It can now be undefined, we have to change the
types accordingly.
2025-03-03 12:27:02 +01:00
Anthony LC 7f6ffa0123 (frontend) add quote blocks to the editor
Add a custom block to quote in the editor.
2025-03-03 12:27:02 +01:00
Samuel Paccoud - DINUM ef2127585c 🐛(backend) allow any type of extensions for media download
The regex to validate media file extensions was too restrictive.
2025-03-03 11:21:41 +01:00
127 changed files with 2235 additions and 2229 deletions
+25 -1
View File
@@ -8,9 +8,29 @@ and this project adheres to
## [Unreleased]
## [2.4.0] - 2025-03-06
## Added
- ✨(frontend) synchronize language-choice #401
## Changed
- Use sentry tags instead of extra scope
## Fixed
- 🐛(frontend) fix collaboration error #684
## [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
- ✨(frontend) synchronize language-choice #401
## Changed
@@ -21,7 +41,10 @@ and this project adheres to
## Fixed
- 🐛(backend) allow any type of extensions for media download #671
- ♻️(frontend) improve table pdf rendering
- 🐛(email) invitation emails in receivers language
## [2.2.0] - 2025-02-10
@@ -409,7 +432,8 @@ 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.2.0...main
[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
[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
@@ -68,6 +68,8 @@ 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 {
-745
View File
@@ -1,745 +0,0 @@
{
"name": "docs",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@ag-media/react-pdf-table": "^2.0.1"
}
},
"node_modules/@ag-media/react-pdf-table": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@ag-media/react-pdf-table/-/react-pdf-table-2.0.1.tgz",
"integrity": "sha512-UMNdGYAfuI6L1wLRziYmwcp/8I2JgbwX+PY7bHXGb2+P6MwgFJH8W71qZO1bxfxrmVUTP8YblQwl1PkXG2m6Rg==",
"license": "MIT",
"peerDependencies": {
"@react-pdf/renderer": "^2.0.2 || ^3.0.0 || ^4.0.0",
"@react-pdf/stylesheet": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"react": "^16.8.6 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@react-pdf/fns": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.1.tgz",
"integrity": "sha512-fYvgOWWRxTdkCciLSla2iek8W/oDLhExPTLPw3aArGPJHgVUc86V2c3YLULNHIBuy/64QVpPLB7gwNkTEW5m/A==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/font": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-3.1.0.tgz",
"integrity": "sha512-5q+r3DhZK41gVZp2Uw5M69FEVWeoasnM/HscW3kdpYnwjcB2bhCRWmBGCjm8fmuwQstwNPM1ZxyCWZRTRchwnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/types": "^2.8.0",
"fontkit": "^2.0.2",
"is-url": "^1.2.4"
}
},
"node_modules/@react-pdf/image": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.2.tgz",
"integrity": "sha512-GrlApNDxLdFKN1ia+nt1svrnpBJIwf2ncK4Km/hQzAkbALn0HQ5YVrOEtMpnp/c0L0o9zO4hSoPL9iEQne5vzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/png-js": "^3.0.0",
"jay-peg": "^1.1.1"
}
},
"node_modules/@react-pdf/layout": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.2.3.tgz",
"integrity": "sha512-sSL14ki0nC8YwSrjkOzKI1ZV4xZC68v/wA5EFWW6IhmJ3qjX4+KbNdprWBCtih/Xbq2Kt9K1erZpKFiWoVf5/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/image": "^3.0.2",
"@react-pdf/pdfkit": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.0.0",
"@react-pdf/textkit": "^5.0.3",
"@react-pdf/types": "^2.8.0",
"emoji-regex": "^10.3.0",
"queue": "^6.0.1",
"yoga-layout": "^3.2.1"
}
},
"node_modules/@react-pdf/layout/node_modules/@react-pdf/stylesheet": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"@react-pdf/types": "^2.8.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/pdfkit": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.2.tgz",
"integrity": "sha512-pyYFAI7YL5Oud60W+wcu9zsN73tg8XgHGtEM8FQ6PY4RgEKp+AXkj+YE2hKuX3eOVB65MPzbJbVWtTjO3MPa5g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/png-js": "^3.0.0",
"browserify-zlib": "^0.2.0",
"crypto-js": "^4.2.0",
"fontkit": "^2.0.2",
"jay-peg": "^1.1.1",
"linebreak": "^1.1.0",
"vite-compatible-readable-stream": "^3.6.1"
}
},
"node_modules/@react-pdf/png-js": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz",
"integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==",
"license": "MIT",
"peer": true,
"dependencies": {
"browserify-zlib": "^0.2.0"
}
},
"node_modules/@react-pdf/primitives": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.1.1.tgz",
"integrity": "sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/reconciler": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.3.tgz",
"integrity": "sha512-4vqY0klmUH32kTFvuqdAszkOpwfZYKMLO4VpJ5xZWTsoUOLQSyhC2QM2QCj9eaxpB2Nd5Kl9uW+KfyutvZnMzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"object-assign": "^4.1.1",
"scheduler": "0.25.0-rc-603e6108-20241029"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/reconciler/node_modules/scheduler": {
"version": "0.25.0-rc-603e6108-20241029",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz",
"integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==",
"license": "MIT",
"peer": true
},
"node_modules/@react-pdf/render": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.1.2.tgz",
"integrity": "sha512-x9R7yaU/EisU2loWLAeVZqUEhkPR1EDa4CXM6PPiPhB2hTZAXgqeZCTVOODX0iGkUBM3scOjzrf5gPPnoMf0jg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/textkit": "^5.0.3",
"@react-pdf/types": "^2.8.0",
"abs-svg-path": "^0.1.1",
"color-string": "^1.9.1",
"normalize-svg-path": "^1.1.0",
"parse-svg-path": "^0.1.2",
"svg-arc-to-cubic-bezier": "^3.2.0"
}
},
"node_modules/@react-pdf/renderer": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.2.2.tgz",
"integrity": "sha512-ldWT9Mi+Ie50oqH0NxYZ1UsnZF7BmhoUiI9GMyXBxMvaiG4lIGR5ki8KWLmA6Ti6+Yp7jXNWg0sYDrvZRLiLjg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.1",
"@react-pdf/font": "^3.1.0",
"@react-pdf/layout": "^4.2.3",
"@react-pdf/pdfkit": "^4.0.2",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/reconciler": "^1.1.3",
"@react-pdf/render": "^4.1.2",
"@react-pdf/types": "^2.8.0",
"events": "^3.3.0",
"object-assign": "^4.1.1",
"prop-types": "^15.6.2",
"queue": "^6.0.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@react-pdf/stylesheet": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-5.2.2.tgz",
"integrity": "sha512-oHP+hZakETrecnZCSRPqNvFhSyBgoZSDOkonY9WJOxRkUb6P6A+mAVSOWBaNt2eM4FHMDpYDeR9stx+gAWn6gg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13",
"@react-pdf/fns": "3.1.0",
"@react-pdf/types": "^2.7.1",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@react-pdf/stylesheet/node_modules/@react-pdf/fns": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.1.0.tgz",
"integrity": "sha512-BjT7C/IeYlrF4Pevlrlo+fILhSxsWSm6Ka/rQrQzYsyQuOsqI6bmBzsTW+T6ghqrD5HLRKr1n8vjAaE9g4rFhA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.13"
}
},
"node_modules/@react-pdf/textkit": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-5.0.3.tgz",
"integrity": "sha512-gRQBw2lOlGl/gZR2O9Joxu3TqlP0u3wy8KVMw3R6glqDSrgLH43cNfdOWIchNvL6adRIjxd8l/FCv2u7zcHqOQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"bidi-js": "^1.0.2",
"hyphen": "^1.6.4",
"unicode-properties": "^1.4.1"
}
},
"node_modules/@react-pdf/types": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.8.0.tgz",
"integrity": "sha512-lBnLonM2GupyTzUGlWTEoUUGvsRcgbWLn0Py3i3lK/tgn2rPCYwJ9gQ5A3warT5g4jQWyc7HmaNoPU/Zy5iBbQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/font": "^3.1.0",
"@react-pdf/primitives": "^4.1.1",
"@react-pdf/stylesheet": "^6.0.0"
}
},
"node_modules/@react-pdf/types/node_modules/@react-pdf/stylesheet": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-6.0.0.tgz",
"integrity": "sha512-uAwuMjbcEaxhRl7tGlqxAbLzo/KoYr6v9JksUJwgzd+rkvAp8jDq8NcG3sUp88tzgIyyRjBGl4FewgdxbAa2uw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-pdf/fns": "3.1.1",
"@react-pdf/types": "^2.8.0",
"color-string": "^1.9.1",
"hsl-to-hex": "^1.0.0",
"media-engine": "^1.0.3",
"postcss-value-parser": "^4.1.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT",
"peer": true
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"license": "MIT",
"peer": true,
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-zlib": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pako": "~1.0.5"
}
},
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"peer": true
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"peer": true,
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT",
"peer": true
},
"node_modules/dfa": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
"license": "MIT",
"peer": true
},
"node_modules/emoji-regex": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz",
"integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==",
"license": "MIT",
"peer": true
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT",
"peer": true
},
"node_modules/fontkit": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@swc/helpers": "^0.5.12",
"brotli": "^1.3.2",
"clone": "^2.1.2",
"dfa": "^1.2.0",
"fast-deep-equal": "^3.1.3",
"restructure": "^3.0.0",
"tiny-inflate": "^1.0.3",
"unicode-properties": "^1.4.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/hsl-to-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz",
"integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==",
"license": "MIT",
"peer": true,
"dependencies": {
"hsl-to-rgb-for-reals": "^1.1.0"
}
},
"node_modules/hsl-to-rgb-for-reals": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz",
"integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==",
"license": "ISC",
"peer": true
},
"node_modules/hyphen": {
"version": "1.10.6",
"resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz",
"integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==",
"license": "ISC",
"peer": true
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"peer": true
},
"node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"license": "MIT",
"peer": true
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT",
"peer": true
},
"node_modules/jay-peg": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz",
"integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==",
"license": "MIT",
"peer": true,
"dependencies": {
"restructure": "^3.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT",
"peer": true
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/media-engine": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz",
"integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==",
"license": "MIT",
"peer": true
},
"node_modules/normalize-svg-path": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz",
"integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==",
"license": "MIT",
"peer": true,
"dependencies": {
"svg-arc-to-cubic-bezier": "^3.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)",
"peer": true
},
"node_modules/parse-svg-path": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz",
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT",
"peer": true
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT",
"peer": true
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
"license": "MIT",
"peer": true
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"peer": true
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
"integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==",
"license": "ISC",
"peer": true
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT",
"peer": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
},
"node_modules/unicode-properties": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
"license": "MIT",
"peer": true,
"dependencies": {
"base64-js": "^1.3.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT",
"peer": true
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"peer": true
},
"node_modules/vite-compatible-readable-stream": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
"integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/yoga-layout": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
"license": "MIT",
"peer": true
}
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"dependencies": {
"@ag-media/react-pdf-table": "^2.0.1"
}
}
-314
View File
@@ -1,314 +0,0 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< * Go to first line in file (or line _N).
G > ESC-> * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> Mark the current top line with <letter>.
M_<_l_e_t_t_e_r_> Mark the current bottom line with <letter>.
'_<_l_e_t_t_e_r_> Go to a previously marked position.
'' Go to the previous position.
^X^X Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after forward-screen.
-W ........ --HILITE-UNREAD
Highlight first new line after any forward movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse input.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
--show-preproc-errors
Display a message if preprocessor exits with an error status.
--proc-backspace
Process backspaces for bold/underline.
--PROC-BACKSPACE
Treat backspaces as control characters.
--proc-return
Delete carriage returns before newline.
--PROC-RETURN
Treat carriage returns as control characters.
--proc-tab
Expand tabs to spaces.
--PROC-TAB
Treat tabs as control characters.
--status-col-width=[_N]
Set the width of the -J status column to _N characters.
--status-line
Highlight or color the entire line containing a mark.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=[_N]
Each click of the mouse wheel moves _N lines.
--wordwrap
Wrap lines at spaces.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
+3 -1
View File
@@ -23,7 +23,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -418,6 +418,7 @@ class FileUploadSerializer(serializers.Serializer):
self.context["expected_extension"] = extension
self.context["content_type"] = magic_mime_type
self.context["file_name"] = file.name
return file
@@ -426,6 +427,7 @@ 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
+26 -8
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-Z]{3,4}"
FILE_EXT_REGEX = r"\.[a-zA-Z0-9]{1,10}"
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}{FILE_EXT_REGEX:s})$"
f"(?P<key>{ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}(?:-unsafe)?{FILE_EXT_REGEX:s})$"
)
COLLABORATION_WS_URL_PATTERN = re.compile(rf"(?:^|&)room=(?P<pk>{UUID_REGEX})(?:&|$)")
@@ -915,15 +915,31 @@ 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(
@@ -1151,13 +1167,14 @@ class DocumentAccessViewSet(
def perform_create(self, serializer):
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
access.document.send_invitation_email(
access.user.email,
access.role,
self.request.user,
language,
access.user.language
or self.request.user.language
or settings.LANGUAGE_CODE,
)
def perform_update(self, serializer):
@@ -1383,10 +1400,11 @@ class InvitationViewset(
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
invitation.document.send_invitation_email(
invitation.email, invitation.role, self.request.user, language
invitation.email,
invitation.role,
self.request.user,
self.request.user.language or settings.LANGUAGE_CODE,
)
@@ -0,0 +1,36 @@
# Generated by Django 5.1.5 on 2025-03-04 12:23
from django.db import migrations, models
import core.models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_update_blank_title"),
]
operations = [
migrations.AlterModelManagers(
name="user",
managers=[
("objects", core.models.UserManager()),
],
),
migrations.AlterField(
model_name="user",
name="language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
],
default=None,
help_text="The language in which the user wants to see the interface.",
max_length=10,
null=True,
verbose_name="language",
),
),
]
+21 -20
View File
@@ -194,9 +194,11 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
default=None,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
null=True,
blank=True,
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
@@ -697,6 +699,7 @@ 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,
}
)
@@ -738,8 +741,12 @@ 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 = _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
subject = (
context["title"]
if not self.title
else _("{name} shared a document with you: {title}").format(
name=sender_name, title=self.title
)
)
self.send_email(subject, [email], context, language)
@@ -787,6 +794,9 @@ class Document(MP_Node, BaseModel):
}
)
# save the current deleted_at value to exclude it from the descendants update
current_deleted_at = self.deleted_at
# Restore the current document
self.deleted_at = None
@@ -794,26 +804,17 @@ 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 = min(ancestors_deleted_at, default=None)
self.save()
self.ancestors_deleted_at = ancestors_deleted_at
self.save(update_fields=["deleted_at", "ancestors_deleted_at"])
# Update descendants excluding those who were deleted prior to the deletion of the
# current document (the ancestor_deleted_at date for those should already by good)
# The number of deleted descendants should not be too big so we can handcraft a union
# clause for them:
deleted_descendants_paths = (
self.get_descendants()
.filter(deleted_at__isnull=False)
.values_list("path", flat=True)
)
exclude_condition = models.Q(
*(models.Q(path__startswith=path) for path in deleted_descendants_paths)
)
self.get_descendants().exclude(exclude_condition).update(
ancestors_deleted_at=self.ancestors_deleted_at
)
self.get_descendants().exclude(
models.Q(deleted_at__isnull=False)
| models.Q(ancestors_deleted_at__lt=current_deleted_at)
).update(ancestors_deleted_at=self.ancestors_deleted_at)
class LinkTrace(BaseModel):
@@ -16,6 +16,9 @@ from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
# Create
def test_api_document_accesses_create_anonymous():
"""Anonymous users should not be allowed to create document accesses."""
document = factories.DocumentFactory()
@@ -123,7 +126,7 @@ def test_api_document_accesses_create_authenticated_administrator(via, mock_user
document=document, team="lasuite", role="administrator"
)
other_user = factories.UserFactory()
other_user = factories.UserFactory(language="en-us")
# It should not be allowed to create an owner access
response = client.post(
@@ -199,7 +202,7 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
document=document, team="lasuite", role="owner"
)
other_user = factories.UserFactory()
other_user = factories.UserFactory(language="en-us")
role = random.choice([role[0] for role in models.RoleChoices.choices])
@@ -235,3 +238,73 @@ def test_api_document_accesses_create_authenticated_owner(via, mock_user_teams):
f"on the following document: {document.title}"
) in email_content
assert "docs/" + str(document.id) + "/" in email_content
@pytest.mark.parametrize("via", VIA)
def test_api_document_accesses_create_email_in_receivers_language(via, mock_user_teams):
"""
The email sent to the accesses to notify them of the adding, should be in their language.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="owner"
)
role = random.choice([role[0] for role in models.RoleChoices.choices])
assert len(mail.outbox) == 0
other_users = (
factories.UserFactory(language="en-us"),
factories.UserFactory(language="fr-fr"),
)
for index, other_user in enumerate(other_users):
expected_language = other_user.language
response = client.post(
f"/api/v1.0/documents/{document.id!s}/accesses/",
{
"user_id": str(other_user.id),
"role": role,
},
format="json",
)
assert response.status_code == 201
assert models.DocumentAccess.objects.filter(user=other_user).count() == 1
new_document_access = models.DocumentAccess.objects.filter(
user=other_user
).get()
other_user_data = serializers.UserSerializer(instance=other_user).data
assert response.json() == {
"id": str(new_document_access.id),
"user": other_user_data,
"team": "",
"role": role,
"abilities": new_document_access.get_abilities(user),
}
assert len(mail.outbox) == index + 1
email = mail.outbox[index]
assert email.to == [other_user_data["email"]]
email_content = " ".join(email.body.split())
email_subject = " ".join(email.subject.split())
if expected_language == "en-us":
assert (
f"{user.full_name} shared a document with you: {document.title}".lower()
in email_subject.lower()
)
elif expected_language == "fr-fr":
assert (
f"{user.full_name} a partagé un document avec vous: {document.title}".lower()
in email_subject.lower()
)
assert "docs/" + str(document.id) + "/" in email_content.lower()
@@ -370,7 +370,7 @@ def test_api_document_invitations_create_privileged_members(
Only owners and administrators should be able to invite new users.
Only owners can invite owners.
"""
user = factories.UserFactory()
user = factories.UserFactory(language="en-us")
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=inviting)
@@ -422,11 +422,11 @@ def test_api_document_invitations_create_privileged_members(
}
def test_api_document_invitations_create_email_from_content_language():
def test_api_document_invitations_create_email_from_senders_language():
"""
The email generated is from the language set in the Content-Language header
When inviting on a document a user who does not exist yet in our database, the invitation email should be sent in the language of the sending user.
"""
user = factories.UserFactory()
user = factories.UserFactory(language="fr-fr")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -444,7 +444,6 @@ def test_api_document_invitations_create_email_from_content_language():
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "fr-fr"},
)
assert response.status_code == 201
@@ -464,50 +463,11 @@ def test_api_document_invitations_create_email_from_content_language():
)
def test_api_document_invitations_create_email_from_content_language_not_supported():
"""
If the language from the Content-Language is not supported
it will display the default language, English.
"""
user = factories.UserFactory()
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
invitation_values = {
"email": "guest@example.com",
"role": "reader",
}
assert len(mail.outbox) == 0
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/documents/{document.id!s}/invitations/",
invitation_values,
format="json",
headers={"Content-Language": "not-supported"},
)
assert response.status_code == 201
assert response.json()["email"] == "guest@example.com"
assert models.Invitation.objects.count() == 1
assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.to == ["guest@example.com"]
email_content = " ".join(email.body.split())
assert f"{user.full_name} shared a document with you!" in email_content
def test_api_document_invitations_create_email_full_name_empty():
"""
If the full name of the user is empty, it will display the email address.
"""
user = factories.UserFactory(full_name="")
user = factories.UserFactory(full_name="", language="en-us")
document = factories.DocumentFactory()
factories.UserDocumentAccessFactory(document=document, user=user, role="owner")
@@ -79,6 +79,7 @@ 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(
@@ -217,6 +218,7 @@ 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):
@@ -291,7 +293,9 @@ 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
@@ -301,6 +305,7 @@ 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():
@@ -340,7 +345,9 @@ 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
@@ -350,3 +357,4 @@ 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"'
@@ -64,6 +64,30 @@ 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):
"""
+5 -1
View File
@@ -39,7 +39,11 @@ def test_api_config(is_authenticated):
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGES": [
["en-us", "English"],
["fr-fr", "Français"],
["de-de", "Deutsch"],
],
"LANGUAGE_CODE": "en-us",
"MEDIA_BASE_URL": "http://testserver/",
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
+1
View File
@@ -158,6 +158,7 @@ def test_api_users_retrieve_me_authenticated():
"id": str(user.id),
"email": user.email,
"full_name": user.full_name,
"language": user.language,
"short_name": user.short_name,
}
@@ -636,6 +636,37 @@ 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.
@@ -765,3 +796,122 @@ def test_models_documents_nb_accesses_cache_is_invalidated_on_access_removal(
new_nb_accesses = document.nb_accesses
assert new_nb_accesses == 0
assert cache.get(key) == 0 # Cache should now contain the new value
def test_models_documents_restore(django_assert_num_queries):
"""The restore method should restore a soft-deleted document."""
document = factories.DocumentFactory()
document.soft_delete()
document.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
with django_assert_num_queries(6):
document.restore()
document.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == document.deleted_at
def test_models_documents_restore_complex(django_assert_num_queries):
"""The restore method should restore a soft-deleted document and its ancestors."""
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child1 = factories.DocumentFactory(parent=document)
child2 = factories.DocumentFactory(parent=document)
# Soft delete first the document
document.soft_delete()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Soft delete the grand parent
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Restore the item
with django_assert_num_queries(8):
document.restore()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
grand_parent.refresh_from_db()
assert document.deleted_at is None
assert document.ancestors_deleted_at == grand_parent.deleted_at
# child 1 and child 2 should now have the same ancestors_deleted_at as the grand parent
assert child1.ancestors_deleted_at == grand_parent.deleted_at
assert child2.ancestors_deleted_at == grand_parent.deleted_at
def test_models_documents_restore_complex_bis(django_assert_num_queries):
"""The restore method should restore a soft-deleted item and its ancestors."""
grand_parent = factories.DocumentFactory()
parent = factories.DocumentFactory(parent=grand_parent)
document = factories.DocumentFactory(parent=parent)
child1 = factories.DocumentFactory(parent=document)
child2 = factories.DocumentFactory(parent=document)
# Soft delete first the document
document.soft_delete()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Soft delete the grand parent
grand_parent.soft_delete()
grand_parent.refresh_from_db()
parent.refresh_from_db()
assert grand_parent.deleted_at is not None
assert grand_parent.ancestors_deleted_at == grand_parent.deleted_at
assert parent.ancestors_deleted_at == grand_parent.deleted_at
# item, child1 and child2 should not be affected
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
# Restoring the grand parent should not restore the document
# as it was deleted before the grand parent
with django_assert_num_queries(7):
grand_parent.restore()
grand_parent.refresh_from_db()
parent.refresh_from_db()
document.refresh_from_db()
child1.refresh_from_db()
child2.refresh_from_db()
assert grand_parent.deleted_at is None
assert grand_parent.ancestors_deleted_at is None
assert parent.deleted_at is None
assert parent.ancestors_deleted_at is None
assert document.deleted_at is not None
assert document.ancestors_deleted_at == document.deleted_at
assert child1.ancestors_deleted_at == document.deleted_at
assert child2.ancestors_deleted_at == document.deleted_at
+6 -11
View File
@@ -7,17 +7,12 @@ NB_OBJECTS = {
}
DEV_USERS = [
{"username": "impress", "email": "impress@impress.world", "language": "en-us"},
{"username": "user-e2e-webkit", "email": "user@webkit.e2e", "language": "en-us"},
{"username": "user-e2e-firefox", "email": "user@firefox.e2e", "language": "en-us"},
{
"username": "impress",
"email": "impress@impress.world",
"username": "user-e2e-chromium",
"email": "user@chromium.e2e",
"language": "en-us",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]
@@ -179,7 +179,8 @@ def create_demo(stdout):
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
language=dev_user["language"]
or random.choice(settings.LANGUAGES)[0],
)
)
+11 -7
View File
@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -210,7 +211,6 @@ class Base(Configuration):
"application/x-ms-regedit",
"application/x-msdownload",
"application/xml",
"image/svg+xml",
]
# Document versions
@@ -221,7 +221,9 @@ class Base(Configuration):
# Languages
LANGUAGE_CODE = values.Value("en-us")
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
# cookie & language is set from frontend
LANGUAGE_COOKIE_NAME = "docs_language"
LANGUAGE_COOKIE_PATH = "/"
DRF_NESTED_MULTIPART_PARSER = {
# output of parser is converted to querydict
@@ -233,9 +235,9 @@ class Base(Configuration):
# fallback/default languages throughout the app.
LANGUAGES = values.SingleNestedTupleValue(
(
("en-us", _("English")),
("fr-fr", _("French")),
("de-de", _("German")),
("en-us", "English"),
("fr-fr", "Français"),
("de-de", "Deutsch"),
)
)
@@ -647,8 +649,10 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
sentry_sdk.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
if (
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
+42 -42
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\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:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr "Benutzer"
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
msgid "title"
msgstr "Titel"
@@ -251,143 +251,143 @@ msgstr "Dokumente"
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:734 core/models.py:734
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:738 core/models.py:738
#, 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:726 core/models.py:726
#: build/lib/core/models.py:741 core/models.py:741
#, 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:762 core/models.py:762
#: build/lib/core/models.py:777 core/models.py:777
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
#: build/lib/core/models.py:784 core/models.py:784
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:837 core/models.py:837
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:838 core/models.py:838
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:844 core/models.py:844
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:867 core/models.py:867
msgid "Document favorite"
msgstr "Dokumentenfavorit"
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:868 core/models.py:868
msgid "Document favorites"
msgstr "Dokumentfavoriten"
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:874 core/models.py:874
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:879 core/models.py:879
#: build/lib/core/models.py:896 core/models.py:896
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:897 core/models.py:897
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:903 core/models.py:903
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:909 core/models.py:909
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
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:926 core/models.py:926
#: build/lib/core/models.py:943 core/models.py:943
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:944 core/models.py:944
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:945 core/models.py:945
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:947 core/models.py:947
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:949 core/models.py:949
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:955 core/models.py:955
msgid "Template"
msgstr "Vorlage"
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:956 core/models.py:956
msgid "Templates"
msgstr "Vorlagen"
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1011 core/models.py:1011
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1017 core/models.py:1017
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "email address"
msgstr "E-Mail-Adresse"
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1065 core/models.py:1065
msgid "Document invitation"
msgstr "Einladung zum Dokument"
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1066 core/models.py:1066
msgid "Document invitations"
msgstr "Dokumenteinladungen"
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1086 core/models.py:1086
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:236 impress/settings.py:236
#: build/lib/impress/settings.py:235 impress/settings.py:235
msgid "English"
msgstr "Englisch"
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "French"
msgstr "Französisch"
#: build/lib/impress/settings.py:238 impress/settings.py:238
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "German"
msgstr "Deutsch"
+42 -42
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\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:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
msgid "title"
msgstr ""
@@ -251,143 +251,143 @@ msgstr ""
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:734 core/models.py:734
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:738 core/models.py:738
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:726 core/models.py:726
#: build/lib/core/models.py:741 core/models.py:741
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
#: build/lib/core/models.py:777 core/models.py:777
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
#: build/lib/core/models.py:784 core/models.py:784
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:837 core/models.py:837
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:838 core/models.py:838
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:844 core/models.py:844
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:867 core/models.py:867
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:868 core/models.py:868
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:874 core/models.py:874
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:896 core/models.py:896
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:897 core/models.py:897
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:903 core/models.py:903
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:909 core/models.py:909
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:926 core/models.py:926
#: build/lib/core/models.py:943 core/models.py:943
msgid "description"
msgstr ""
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:944 core/models.py:944
msgid "code"
msgstr ""
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:945 core/models.py:945
msgid "css"
msgstr ""
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:947 core/models.py:947
msgid "public"
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:949 core/models.py:949
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:955 core/models.py:955
msgid "Template"
msgstr ""
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:956 core/models.py:956
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1011 core/models.py:1011
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1017 core/models.py:1017
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1065 core/models.py:1065
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1066 core/models.py:1066
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1086 core/models.py:1086
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:235 impress/settings.py:235
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "German"
msgstr ""
+43 -43
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-04 08:20\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:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
msgid "title"
msgstr ""
@@ -249,145 +249,145 @@ msgstr ""
#: build/lib/core/models.py:418 core/models.py:418
msgid "Untitled Document"
msgstr ""
msgstr "Document sans titre"
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:734 core/models.py:734
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:738 core/models.py:738
#, 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:726 core/models.py:726
#: build/lib/core/models.py:741 core/models.py:741
#, 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:762 core/models.py:762
#: build/lib/core/models.py:777 core/models.py:777
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
#: build/lib/core/models.py:784 core/models.py:784
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:837 core/models.py:837
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:838 core/models.py:838
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:844 core/models.py:844
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:867 core/models.py:867
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:868 core/models.py:868
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:874 core/models.py:874
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:896 core/models.py:896
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:897 core/models.py:897
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:903 core/models.py:903
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:909 core/models.py:909
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:926 core/models.py:926
#: build/lib/core/models.py:943 core/models.py:943
msgid "description"
msgstr ""
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:944 core/models.py:944
msgid "code"
msgstr ""
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:945 core/models.py:945
msgid "css"
msgstr ""
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:947 core/models.py:947
msgid "public"
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:949 core/models.py:949
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:955 core/models.py:955
msgid "Template"
msgstr ""
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:956 core/models.py:956
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1011 core/models.py:1011
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1017 core/models.py:1017
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1065 core/models.py:1065
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1066 core/models.py:1066
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1086 core/models.py:1086
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:235 impress/settings.py:235
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "German"
msgstr ""
+42 -42
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"POT-Creation-Date: 2025-03-03 12:20+0000\n"
"PO-Revision-Date: 2025-03-03 12:22\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:453 core/api/serializers.py:453
#: build/lib/core/api/serializers.py:455 core/api/serializers.py:455
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:456 core/api/serializers.py:456
#: build/lib/core/api/serializers.py:458 core/api/serializers.py:458
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:462 core/api/serializers.py:462
#: build/lib/core/api/serializers.py:464 core/api/serializers.py:464
msgid "Format"
msgstr ""
@@ -230,8 +230,8 @@ msgstr ""
msgid "users"
msgstr ""
#: build/lib/core/models.py:373 build/lib/core/models.py:925 core/models.py:373
#: core/models.py:925
#: build/lib/core/models.py:373 build/lib/core/models.py:942 core/models.py:373
#: core/models.py:942
msgid "title"
msgstr ""
@@ -251,143 +251,143 @@ msgstr ""
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:719 core/models.py:719
#: build/lib/core/models.py:734 core/models.py:734
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
#: build/lib/core/models.py:738 core/models.py:738
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr ""
#: build/lib/core/models.py:726 core/models.py:726
#: build/lib/core/models.py:741 core/models.py:741
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr ""
#: build/lib/core/models.py:762 core/models.py:762
#: build/lib/core/models.py:777 core/models.py:777
msgid "This document is not deleted."
msgstr ""
#: build/lib/core/models.py:769 core/models.py:769
#: build/lib/core/models.py:784 core/models.py:784
msgid "This document was permanently deleted and cannot be restored."
msgstr ""
#: build/lib/core/models.py:820 core/models.py:820
#: build/lib/core/models.py:837 core/models.py:837
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:821 core/models.py:821
#: build/lib/core/models.py:838 core/models.py:838
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:827 core/models.py:827
#: build/lib/core/models.py:844 core/models.py:844
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:850 core/models.py:850
#: build/lib/core/models.py:867 core/models.py:867
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:851 core/models.py:851
#: build/lib/core/models.py:868 core/models.py:868
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:857 core/models.py:857
#: build/lib/core/models.py:874 core/models.py:874
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr ""
#: build/lib/core/models.py:879 core/models.py:879
#: build/lib/core/models.py:896 core/models.py:896
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:880 core/models.py:880
#: build/lib/core/models.py:897 core/models.py:897
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:886 core/models.py:886
#: build/lib/core/models.py:903 core/models.py:903
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:892 core/models.py:892
#: build/lib/core/models.py:909 core/models.py:909
msgid "This team is already in this document."
msgstr ""
#: build/lib/core/models.py:898 build/lib/core/models.py:1012
#: core/models.py:898 core/models.py:1012
#: build/lib/core/models.py:915 build/lib/core/models.py:1029
#: core/models.py:915 core/models.py:1029
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:926 core/models.py:926
#: build/lib/core/models.py:943 core/models.py:943
msgid "description"
msgstr ""
#: build/lib/core/models.py:927 core/models.py:927
#: build/lib/core/models.py:944 core/models.py:944
msgid "code"
msgstr ""
#: build/lib/core/models.py:928 core/models.py:928
#: build/lib/core/models.py:945 core/models.py:945
msgid "css"
msgstr ""
#: build/lib/core/models.py:930 core/models.py:930
#: build/lib/core/models.py:947 core/models.py:947
msgid "public"
msgstr ""
#: build/lib/core/models.py:932 core/models.py:932
#: build/lib/core/models.py:949 core/models.py:949
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:938 core/models.py:938
#: build/lib/core/models.py:955 core/models.py:955
msgid "Template"
msgstr ""
#: build/lib/core/models.py:939 core/models.py:939
#: build/lib/core/models.py:956 core/models.py:956
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:993 core/models.py:993
#: build/lib/core/models.py:1010 core/models.py:1010
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:994 core/models.py:994
#: build/lib/core/models.py:1011 core/models.py:1011
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:1000 core/models.py:1000
#: build/lib/core/models.py:1017 core/models.py:1017
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:1006 core/models.py:1006
#: build/lib/core/models.py:1023 core/models.py:1023
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:1029 core/models.py:1029
#: build/lib/core/models.py:1046 core/models.py:1046
msgid "email address"
msgstr ""
#: build/lib/core/models.py:1048 core/models.py:1048
#: build/lib/core/models.py:1065 core/models.py:1065
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:1049 core/models.py:1049
#: build/lib/core/models.py:1066 core/models.py:1066
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:1069 core/models.py:1069
#: build/lib/core/models.py:1086 core/models.py:1086
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: build/lib/impress/settings.py:235 impress/settings.py:235
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: build/lib/impress/settings.py:236 impress/settings.py:236
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
#: build/lib/impress/settings.py:237 impress/settings.py:237
msgid "German"
msgstr ""
+17 -17
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.2.0"
version = "2.4.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,37 +25,37 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"boto3==1.36.7",
"boto3==1.37.5",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.6.0",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-filter==24.3",
"django-filter==25.1",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.4",
"django-storages[s3]==1.14.5",
"django-timezone-field>=5.1",
"django==5.1.5",
"django==5.1.6",
"django-treebeard==4.7.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.60.2",
"psycopg[binary]==3.2.4",
"openai==1.65.2",
"psycopg[binary]==3.2.5",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"requests==2.32.3",
"sentry-sdk==2.20.0",
"sentry-sdk==2.22.0",
"url-normalize==1.4.3",
"whitenoise==6.8.2",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
]
@@ -69,21 +69,21 @@ dependencies = [
dev = [
"django-extensions==3.2.3",
"django-test-migrations==1.4.0",
"drf-spectacular-sidecar==2024.12.1",
"drf-spectacular-sidecar==2025.3.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"ipython==9.0.1",
"pyfakefs==5.7.4",
"pylint-django==2.6.1",
"pylint==3.3.4",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.6",
"ruff==0.9.3",
"types-requests==2.32.0.20241016",
"ruff==0.9.9",
"types-requests==2.32.0.20250301",
]
[tool.setuptools]
@@ -0,0 +1,22 @@
<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>
@@ -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.getByLabel(role).click();
await page.getByRole('button', { name: 'Invite' }).click();
return users[index].email;
@@ -12,8 +12,8 @@ const config = {
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'French'],
['de-de', 'German'],
['fr-fr', 'Français'],
['de-de', 'Deutsch'],
],
LANGUAGE_CODE: 'en-us',
POSTHOG_KEY: {},
@@ -1,8 +1,7 @@
/* 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,
@@ -16,41 +15,6 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('it check translations of the slash menu when changing language', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const header = page.locator('header').first();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Reset menu
await editor.click();
await editor.fill('');
// Change language to French
await header.click();
await header.getByRole('button', { name: /Language/ }).click();
await page.getByRole('menuitem', { name: 'Français' }).click();
await expect(
header.getByRole('button').getByText('Français'),
).toBeVisible();
// Trigger slash menu to show french menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
});
test('it checks default toolbar buttons are displayed', async ({
page,
browserName,
@@ -129,11 +93,7 @@ test.describe('Doc Editor', () => {
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
await page.getByLabel('Connected').click();
// Assert that the doc reconnects to the ws
const wsClose = await wsClosePromise;
@@ -415,6 +375,8 @@ 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;
@@ -441,6 +403,45 @@ 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');
});
});
@@ -197,4 +197,49 @@ 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
});
});
@@ -88,20 +88,14 @@ test.describe('Doc Header', () => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Delete document',
})
.click();
await page.getByLabel('Delete document').click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete the document "${randomDoc}"?`,
),
page.getByText(`Are you sure you want to delete this document ?`),
).toBeVisible();
await page
@@ -152,9 +146,7 @@ test.describe('Doc Header', () => {
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -176,11 +168,7 @@ test.describe('Doc Header', () => {
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
await expect(page.getByLabel('Delete')).toBeEnabled();
await invitationCard.click();
const memberCard = shareModal.getByLabel('List members card');
@@ -194,11 +182,7 @@ test.describe('Doc Header', () => {
).toBeVisible();
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('menuitem', {
name: 'delete',
}),
).toBeEnabled();
await expect(page.getByLabel('Delete')).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -232,9 +216,7 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -294,9 +276,7 @@ test.describe('Doc Header', () => {
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Delete document' }),
).toBeDisabled();
await expect(page.getByLabel('Delete document')).toBeDisabled();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -352,7 +332,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.getByLabel('Copy as Markdown').click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in Markdown format
@@ -387,7 +367,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.getByLabel('Copy as HTML').click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
@@ -460,7 +440,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.getByLabel('Share').click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
@@ -494,7 +474,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.getByLabel('Share').click();
await expect(page.getByLabel('Share modal')).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
@@ -65,15 +65,13 @@ 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('menuitem', { name: 'Administrator' }),
).toBeVisible();
await expect(page.getByLabel('Reader')).toBeVisible();
await expect(page.getByLabel('Editor')).toBeVisible();
await expect(page.getByLabel('Owner')).toBeVisible();
await expect(page.getByLabel('Administrator')).toBeVisible();
// Validate
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
@@ -121,7 +119,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.getByLabel('Owner').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -139,7 +137,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.getByLabel('Owner').click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
@@ -155,47 +153,6 @@ test.describe('Document create member', () => {
expect(responseCreateInvitationFail.ok()).toBeFalsy();
});
test('The invitation endpoint get the language of the website', async ({
page,
browserName,
}) => {
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 page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Saisie de recherche rapide',
});
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// 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();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation sent
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('fr-fr');
});
test('it manages invitation', async ({ page, browserName }) => {
await createDoc(page, 'user-invitation', browserName, 1);
@@ -212,7 +169,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.getByLabel('Administrator').click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -232,14 +189,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.getByLabel('Reader').click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
});
await moreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete').click();
await expect(userInvitation).toBeHidden();
});
@@ -161,12 +161,12 @@ test.describe('Document list members', () => {
await list.click();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Administrator' }).click();
await page.getByLabel('Administrator').click();
await list.click();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
await page.getByLabel('Reader').click();
await list.click();
await expect(currentUserRole).toBeHidden();
});
@@ -215,11 +215,11 @@ test.describe('Document list members', () => {
await expect(mySelfMoreActions).toBeVisible();
await userReaderMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete').click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByLabel('Delete').click();
await expect(
page.getByText('You do not have permission to view this document.'),
).toBeVisible();
@@ -18,11 +18,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, randomDoc);
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Version history',
})
.click();
await page.getByLabel('Version history').click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
@@ -58,11 +54,7 @@ test.describe('Doc Version', () => {
).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Version history',
})
.click();
await page.getByLabel('Version history').click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
@@ -90,9 +82,7 @@ test.describe('Doc Version', () => {
await verifyDocName(page, 'Mocked document');
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('menuitem', { name: 'Version history' }),
).toBeDisabled();
await expect(page.getByLabel('Version history')).toBeDisabled();
});
test('it restores the doc version', async ({ page, browserName }) => {
@@ -119,11 +109,7 @@ test.describe('Doc Version', () => {
await expect(page.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('menuitem', {
name: 'Version history',
})
.click();
await page.getByLabel('Version history').click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
@@ -49,21 +49,13 @@ test.describe('Doc Visibility', () => {
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Connected',
})
.click();
await page.getByLabel('Connected').click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await selectVisibility.click();
await page
.getByRole('menuitem', {
name: 'Public',
})
.click();
await page.getByLabel('Public', { exact: true }).click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
});
@@ -162,7 +154,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.getByLabel('Administrator').click();
await page.getByRole('button', { name: 'Invite' }).click();
@@ -26,6 +26,7 @@ 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();
@@ -1,19 +1,41 @@
import { expect, test } from '@playwright/test';
import { Page, expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
import { createDoc } from './common';
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(page.getByLabel('Logout')).toBeVisible();
test.describe.serial('Language', () => {
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.afterAll(async () => {
await page.close();
});
test.beforeEach(async ({ page }) => {
await page.goto('/');
await waitForLanguageSwitch(page, TestLanguage.English);
});
test.afterEach(async ({ page }) => {
// Switch back to English - important for other tests to run as expected
await waitForLanguageSwitch(page, TestLanguage.English);
});
test('checks language switching', async ({ page }) => {
const header = page.locator('header').first();
await header
.getByRole('button', { name: /Language/ })
.getByText('English')
.click();
await page.getByRole('menuitem', { name: 'Français' }).click();
// initial language should be english
await expect(
page.getByRole('button', {
name: 'New doc',
}),
).toBeVisible();
// switch to french
await waitForLanguageSwitch(page, TestLanguage.French);
await expect(
header.getByRole('button').getByText('Français'),
).toBeVisible();
@@ -21,7 +43,7 @@ test.describe('Language', () => {
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await header.getByRole('button').getByText('Français').click();
await page.getByRole('menuitem', { name: 'Deutsch' }).click();
await page.getByLabel('Deutsch').click();
await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
@@ -52,15 +74,76 @@ test.describe('Language', () => {
// Check for English 404 response
await check404Response('Not found.');
// 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 waitForLanguageSwitch(page, TestLanguage.French);
// Check for French 404 response
await check404Response('Pas trouvé.');
});
test('it check translations of the slash menu when changing language', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const header = page.locator('header').first();
const editor = page.locator('.ProseMirror');
// Trigger slash menu to show english menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Headings', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Headings', { exact: true })).toBeHidden();
// Reset menu
await editor.click();
await editor.fill('');
// Change language to French
await waitForLanguageSwitch(page, TestLanguage.French);
// Trigger slash menu to show french menu
await editor.click();
await editor.fill('/');
await expect(page.getByText('Titres', { exact: true })).toBeVisible();
await header.click();
await expect(page.getByText('Titres', { exact: true })).toBeHidden();
});
});
// language helper
export const TestLanguage = {
English: {
label: 'English',
expectedLocale: ['en-us'],
},
French: {
label: 'Français',
expectedLocale: ['fr-fr'],
},
German: {
label: 'Deutsch',
expectedLocale: ['de-de'],
},
} as const;
type TestLanguageKey = keyof typeof TestLanguage;
type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey];
export async function waitForLanguageSwitch(
page: Page,
lang: TestLanguageValue,
) {
const header = page.locator('header').first();
await header.getByRole('button', { name: 'arrow_drop_down' }).click();
const responsePromise = page.waitForResponse(
(resp) =>
resp.url().includes('/user') && resp.request().method() === 'PATCH',
);
await page.getByLabel(lang.label).click();
const resolvedResponsePromise = await responsePromise;
const responseData = await resolvedResponsePromise.json();
expect(lang.expectedLocale).toContain(responseData.language);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "2.2.0",
"version": "2.4.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+9
View File
@@ -357,6 +357,15 @@ 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',
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "2.2.0",
"version": "2.4.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,11 +16,11 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.1",
"@blocknote/core": "0.23.2",
"@blocknote/mantine": "0.23.2",
"@blocknote/react": "0.23.2",
"@blocknote/xl-docx-exporter": "0.23.2",
"@blocknote/xl-pdf-exporter": "0.23.2",
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@blocknote/xl-docx-exporter": "*",
"@blocknote/xl-pdf-exporter": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "2.9.4",
@@ -53,14 +53,11 @@ export const DropButton = ({
}: PropsWithChildren<DropButtonProps>) => {
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const triggerRef = useRef<HTMLButtonElement>(null);
const firstFocusableRef = useRef<HTMLButtonElement>(null);
const triggerRef = useRef(null);
useEffect(() => {
if (isLocalOpen && firstFocusableRef.current) {
firstFocusableRef.current.focus();
}
}, [isLocalOpen]);
setIsLocalOpen(isOpen);
}, [isOpen]);
const onOpenChangeHandler = (isOpen: boolean) => {
setIsLocalOpen(isOpen);
@@ -35,6 +35,7 @@ export const TextErrors = ({
<Text
key={`causes-${i}`}
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
@@ -43,7 +44,12 @@ export const TextErrors = ({
))}
{!causes && (
<Text $theme="danger" $textAlign="center" {...textProps}>
<Text
$theme="danger"
$variation="600"
$textAlign="center"
{...textProps}
>
{defaultMessage || t('Something bad happens, please retry.')}
</Text>
)}
@@ -4,7 +4,6 @@ import { useEffect } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { Auth } from '@/features/auth';
import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { ConfigProvider } from './config/';
@@ -3,7 +3,8 @@ import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { PostHogProvider, configureCrispSession } from '@/services';
import { useLanguageSynchronizer } from '@/features/language/hooks/useLanguageSynchronizer';
import { CrispProvider, PostHogProvider } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
@@ -12,6 +13,7 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
const { synchronizeLanguage } = useLanguageSynchronizer();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
@@ -30,12 +32,8 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
configureCrispSession(conf.CRISP_WEBSITE_ID);
}, [conf?.CRISP_WEBSITE_ID]);
void synchronizeLanguage();
}, [synchronizeLanguage]);
if (!conf) {
return (
@@ -45,5 +43,11 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
);
}
return <PostHogProvider conf={conf.POSTHOG_KEY}>{children}</PostHogProvider>;
return (
<PostHogProvider conf={conf.POSTHOG_KEY}>
<CrispProvider websiteId={conf?.CRISP_WEBSITE_ID}>
{children}
</CrispProvider>
</PostHogProvider>
);
};
@@ -310,7 +310,7 @@ input:-webkit-autofill:focus {
}
/**
* Others
* Checkbox
*/
.c__checkbox:focus-within {
border-color: transparent;
@@ -365,7 +365,8 @@ input:-webkit-autofill:focus {
}
.c__button--medium {
padding: 0.9rem var(--c--theme--spacings--s);
height: auto;
min-height: var(--c--components--button--medium-height);
}
.c__button--small {
@@ -551,6 +552,8 @@ input:-webkit-autofill:focus {
.c__modal__close .c__button {
padding: 0 !important;
top: -0.65rem;
right: -0.65rem;
}
.c__modal--full .c__modal__content {
@@ -610,19 +613,21 @@ input:-webkit-autofill:focus {
padding: 4px 6px;
}
button:focus {
/**
* 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--button--primary-text--background--color-hover
--c--components--alert--error--close--background-color-hover
);
border-radius: var(--c--components--button--border-radius--focus);
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-400)
}
.new-doc-button {
color: white !important;
}
.new-doc-button:focus,
.new-doc-button:focus-visible {
color: white !important;
}
@@ -484,6 +484,19 @@
--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;
@@ -483,7 +483,18 @@ export const tokens = {
},
},
components: {
alert: { 'border-radius': '0' },
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' },
button: {
'medium-height': '40px',
@@ -4,10 +4,12 @@
* @property {string} id - The id of the user.
* @property {string} email - The email of the user.
* @property {string} name - The name of the user.
* @property {string} language - The language of the user. e.g. 'en-us', 'fr-fr', 'de-de'.
*/
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: string;
}
@@ -1,6 +1,6 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, fetchAPI } from '@/api';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from './types';
@@ -17,7 +17,10 @@ import { User } from './types';
export const getMe = async (): Promise<User> => {
const response = await fetchAPI(`users/me/`);
if (!response.ok) {
throw new Error(`Couldn't fetch user data: ${response.statusText}`);
throw new APIError(
`Couldn't fetch user data: ${response.statusText}`,
await errorCauses(response),
);
}
return response.json() as Promise<User>;
};
@@ -5,6 +5,7 @@ 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 } =
@@ -19,6 +20,22 @@ 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.
*/
@@ -2,13 +2,12 @@ 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, replace } = useRouter();
const { pathname } = useRouter();
const [pathAllowed, setPathAllowed] = useState<boolean>(
!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)),
@@ -18,17 +17,10 @@ export const useAuth = () => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!pathname.match(regexp)));
}, [pathname]);
// 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 };
return {
user,
authenticated: !!user && authStates.isSuccess,
pathAllowed,
...authStates,
};
};
@@ -1,4 +1,4 @@
export * from './api/types';
export * from './api';
export * from './components';
export * from './hooks';
export * from './utils';
@@ -1,4 +1,10 @@
import { BlockNoteSchema, Dictionary, locales } from '@blocknote/core';
import {
BlockNoteSchema,
Dictionary,
defaultBlockSpecs,
locales,
withPageBreak,
} from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -20,9 +26,17 @@ import { cssEditor } from '../styles';
import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolbar';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { QuoteBlock } from './custom-blocks';
export const blockNoteSchema = BlockNoteSchema.create();
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
quote: QuoteBlock,
},
}),
);
interface BlockNoteEditorProps {
doc: Doc;
@@ -37,7 +51,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const readOnly = !doc.abilities.partial_update;
useSaveDoc(doc.id, provider.document, !readOnly);
const { i18n } = useTranslation();
const lang = i18n.language;
const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
@@ -120,7 +134,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
$css={cssEditor(readOnly)}
>
{errorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
causes={errorAttachment.cause}
canClose
@@ -136,8 +150,8 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editable={!readOnly}
theme="light"
>
<BlockNoteToolbar />
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
</BlockNoteView>
</Box>
);
@@ -5,13 +5,19 @@ import {
getDefaultReactSlashMenuItems,
getPageBreakReactSlashMenuItems,
useBlockNoteEditor,
useDictionary,
} from '@blocknote/react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { DocsBlockNoteEditor } from '../types';
import { DocsBlockSchema } from '../types';
import { getQuoteReactSlashMenuItems } from './custom-blocks';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
const editor = useBlockNoteEditor<DocsBlockSchema>();
const { t } = useTranslation();
const basicBlocksName = useDictionary().slash_menu.page_break.group;
const getSlashMenuItems = useMemo(() => {
return async (query: string) =>
@@ -20,11 +26,12 @@ export const BlockNoteSuggestionMenu = () => {
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
getQuoteReactSlashMenuItems(editor, t, basicBlocksName),
),
query,
),
);
}, [editor]);
}, [basicBlocksName, editor, t]);
return (
<SuggestionMenuController
@@ -20,7 +20,7 @@ import {
AITransformActions,
useDocAITransform,
useDocAITranslate,
} from '../api/';
} from '../../api';
type LanguageTranslate = {
value: string;
@@ -0,0 +1,75 @@
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}
/>
)}
</>
);
};
@@ -0,0 +1,111 @@
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()}
/>
</>
);
};
@@ -0,0 +1,74 @@
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>
);
};
@@ -1,30 +0,0 @@
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} />;
};
@@ -0,0 +1,77 @@
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',
});
@@ -0,0 +1 @@
export * from './QuoteBlock';
@@ -1 +1,2 @@
export * from './DocEditor';
export * from './custom-blocks/';
@@ -6,6 +6,14 @@ 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;
}
@@ -17,8 +17,12 @@ 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<
typeof blockNoteSchema.blockSchema,
typeof blockNoteSchema.inlineContentSchema,
typeof blockNoteSchema.styleSchema
DocsBlockSchema,
DocsInlineContentSchema,
DocsStyleSchema
>;
@@ -0,0 +1,25 @@
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>
);
};
@@ -0,0 +1,5 @@
export * from './headingPDF';
export * from './paragraphPDF';
export * from './quoteDocx';
export * from './quotePDF';
export * from './tablePDF';
@@ -0,0 +1,31 @@
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>
);
};
@@ -0,0 +1,33 @@
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),
});
};
@@ -0,0 +1,21 @@
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>
);
};
@@ -0,0 +1,52 @@
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>
);
};
@@ -1,11 +1,5 @@
import {
DOCXExporter,
docxDefaultSchemaMappings,
} from '@blocknote/xl-docx-exporter';
import {
PDFExporter,
pdfDefaultSchemaMappings,
} from '@blocknote/xl-pdf-exporter';
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
Button,
Loader,
@@ -15,20 +9,20 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { Text as PDFText, pdf } from '@react-pdf/renderer';
import { 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 } from '@/features/docs/doc-management';
import { Doc, useTrans } 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',
@@ -51,6 +45,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const [format, setFormat] = useState<DocDownloadFormat>(
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const templateOptions = useMemo(() => {
const templateOptions = (templates?.pages || [])
@@ -78,7 +73,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true);
const title = doc.title
const title = (doc.title || untitledDocument)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
@@ -95,91 +90,25 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
if (format === DocDownloadFormat.PDF) {
const defaultExporter = new PDFExporter(
editor.schema,
pdfDefaultSchemaMappings,
pdfDocsSchemaMappings,
);
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 exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
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,
docxDefaultSchemaMappings,
docxDocsSchemaMappings,
);
const exporter = new DOCXExporter(
editor.schema,
docxDefaultSchemaMappings,
{
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
},
);
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
});
blobExport = await exporter.toBlob(exportDocument);
}
@@ -0,0 +1 @@
export * from './ModalExport';
@@ -0,0 +1,2 @@
export * from './components';
export * from './utils';
@@ -0,0 +1,12 @@
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,
},
};
@@ -0,0 +1,20 @@
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,
},
};
@@ -0,0 +1,53 @@
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
>;
@@ -0,0 +1,75 @@
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);
})(),
};
}
@@ -33,11 +33,13 @@ 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"
@@ -45,7 +47,7 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
$size={isMobile ? 'h4' : 'h2'}
$variation="1000"
>
{title}
{title || untitledDocument}
</Text>
);
};
@@ -114,6 +116,8 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
handleTitleSubmit(event.target.textContent || '')
}
$color={colorsTokens()['greyscale-1000']}
$minHeight="40px"
$padding={{ right: 'big' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
@@ -18,6 +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,
@@ -30,8 +31,6 @@ import {
} from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalExport } from './ModalExport';
interface DocToolBoxProps {
doc: Doc;
}
@@ -6,7 +6,7 @@ import { useCunninghamTheme } from '@/cunningham';
import { DocTitleText } from './DocTitle';
interface DocVersionHeaderProps {
title: string;
title?: string;
}
export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {
@@ -1,76 +0,0 @@
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>
);
};
@@ -1,18 +0,0 @@
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;
}
@@ -1,32 +0,0 @@
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;
};
@@ -87,9 +87,7 @@ 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 the document "{{title}}"?', {
title: doc.title,
})}
{t('Are you sure you want to delete this document ?')}
</Text>
)}
@@ -36,7 +36,7 @@ export type Base64 = string;
export interface Doc {
id: string;
title: string;
title?: string;
content: Base64;
creator: string;
is_favorite: boolean;
@@ -10,7 +10,6 @@ import {
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/doc-share';
import { ContentLanguage } from '@/i18n/types';
import { useBroadcastStore } from '@/stores';
import { OptionType } from '../types';
@@ -21,20 +20,15 @@ interface CreateDocAccessParams {
role: Role;
docId: Doc['id'];
memberId: User['id'];
contentLanguage: ContentLanguage;
}
export const createDocAccess = async ({
memberId,
role,
docId,
contentLanguage,
}: CreateDocAccessParams): Promise<Access> => {
const response = await fetchAPI(`documents/${docId}/accesses/`, {
method: 'POST',
headers: {
'Content-Language': contentLanguage,
},
body: JSON.stringify({
user_id: memberId,
role,
@@ -4,7 +4,6 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth';
import { Doc, Role } from '@/features/docs/doc-management';
import { Invitation, OptionType } from '@/features/docs/doc-share/types';
import { ContentLanguage } from '@/i18n/types';
import { KEY_LIST_DOC_INVITATIONS } from './useDocInvitations';
@@ -12,20 +11,15 @@ interface CreateDocInvitationParams {
email: User['email'];
role: Role;
docId: Doc['id'];
contentLanguage: ContentLanguage;
}
export const createDocInvitation = async ({
email,
role,
docId,
contentLanguage,
}: CreateDocInvitationParams): Promise<Invitation> => {
const response = await fetchAPI(`documents/${docId}/invitations/`, {
method: 'POST',
headers: {
'Content-Language': contentLanguage,
},
body: JSON.stringify({
email,
role,
@@ -12,7 +12,6 @@ import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { User } from '@/features/auth';
import { Doc, Role } from '@/features/docs';
import { useLanguage } from '@/i18n/hooks/useLanguage';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
import { OptionType } from '../types';
@@ -42,7 +41,6 @@ export const DocShareAddMemberList = ({
const { toast } = useToastProvider();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { contentLanguage } = useLanguage();
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
const canShare = doc.abilities.accesses_manage;
const spacing = spacingsTokens();
@@ -90,7 +88,6 @@ export const DocShareAddMemberList = ({
const payload = {
role: invitationRole,
docId: doc.id,
contentLanguage,
};
return isInvitationMode
@@ -30,6 +30,7 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
full_name: invitation.email,
email: invitation.email,
short_name: invitation.email,
language: 'en-us',
};
const { toast } = useToastProvider();
@@ -132,6 +132,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
full_name: '',
email: userQuery,
short_name: '',
language: '',
};
const hasEmailInUsers = users.some((user) => user.email === userQuery);
@@ -54,9 +54,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$css={css`
flex: ${flexLeft};
align-items: center;
&:focus {
outline: 2px solidrgb(33, 34, 82);
}
min-width: 0;
`}
href={`/docs/${doc.id}`}
@@ -50,7 +50,7 @@ export const SimpleDocItem = ({
{isPinned ? (
<PinnedDocumentIcon aria-label={t('Pin document icon')} />
) : (
<SimpleFileIcon aria-hidden="true" />
<SimpleFileIcon aria-label={t('Simple document icon')} />
)}
</Box>
<Box $justify="center" $overflow="auto">
@@ -7,11 +7,9 @@ import { createGlobalStyle } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
const GaufreStyle = createGlobalStyle`
&:focus {
outline: 2px solidrgb(33, 34, 82);
.lasuite-gaufre-btn{
box-shadow: inset 0 0 0 0 !important;
}
`;
export const LaGaufre = () => {
@@ -1,7 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { Trans, useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { useTranslation } from 'react-i18next';
import DocLogo from '@/assets/icons/icon-docs.svg?url';
import { Box, Text } from '@/components';
@@ -10,137 +8,15 @@ 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 <HomeProConnect />;
} else {
return <HomeOpenSource />;
if (!withProConnect) {
return null;
}
}
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>
}
/>
);
return <HomeProConnect />;
}
function HomeProConnect() {
@@ -1,10 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Button } from '@openfun/cunningham-react';
import { Trans, useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Footer } from '@/features/footer';
import { LeftPanel } from '@/features/left-panel';
import { useLanguage } from '@/i18n/hooks/useLanguage';
import { useResponsiveStore } from '@/stores';
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
@@ -17,6 +18,8 @@ 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';
@@ -24,10 +27,10 @@ import { HomeHeader, getHeaderHeight } from './HomeHeader';
import { HomeSection } from './HomeSection';
export function HomeContent() {
const { t } = useTranslation();
const { isMobile, isSmallMobile } = useResponsiveStore();
const lang = useLanguage();
const isFrLanguage = lang.language === 'fr';
const { i18n, t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile, isSmallMobile, isTablet } = useResponsiveStore();
const isFrLanguage = i18n.resolvedLanguage === 'fr';
return (
<Box as="main">
@@ -58,19 +61,142 @@ export function HomeContent() {
$gap={isMobile ? '115px' : '230px'}
$padding={{ bottom: '3rem' }}
>
<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 $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={false}
isSmallDevice={isMobile}
@@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { Box, BoxType, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
@@ -18,10 +18,12 @@ export type HomeSectionProps = {
reverse?: boolean;
textWidth?: string;
video?: string;
$css?: BoxType['$css'];
};
export const HomeSection = ({
availableSoon = false,
$css,
description,
illustration,
isSmallDevice,
@@ -89,6 +91,7 @@ export const HomeSection = ({
$hasTransition="slow"
$css={css`
opacity: ${isVisible ? 1 : 0};
${$css}
`}
>
<Box
@@ -122,7 +125,11 @@ export const HomeSection = ({
>
{title}
</Text>
<Text $variation="700" $weight="400" $size="md">
<Text
$variation="700"
$weight="400"
$size={isSmallMobile ? 'ml' : 'md'}
>
{description}
</Text>
</Box>
@@ -4,48 +4,45 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
import { useConfig } from '@/core';
import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer';
import { getMatchingLocales } from './utils/locale';
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
const language = i18n.language;
const { data: conf } = useConfig();
const { synchronizeLanguage } = useLanguageSynchronizer();
const language = i18n.languages[0];
Settings.defaultLocale = language;
// Compute options for dropdown
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
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" lang={lang}>
{LANGUAGES_ALLOWED[lang]}
</Text>
</Box>
),
label: LANGUAGES_ALLOWED[lang],
isSelected: language === lang,
callback: () => {
i18n.changeLanguage(lang).catch((err) => {
console.error('Error changing language', err);
});
},
}));
}, [i18n, language, languages]);
const backendOptions = conf?.LANGUAGES ?? [[language, language]];
return backendOptions.map(([backendLocale, label]) => {
// Determine if the option is selected
const isSelected =
getMatchingLocales([backendLocale], [language]).length > 0;
// Define callback for updating both frontend and backend languages
const callback = () => {
i18n
.changeLanguage(backendLocale)
.then(() => {
void synchronizeLanguage('toBackend');
})
.catch((err) => {
console.error('Error changing language', err);
});
};
return { label, isSelected, callback };
});
}, [conf, i18n, language, synchronizeLanguage]);
// Extract current language label for display
const currentLanguageLabel =
conf?.LANGUAGES.find(
([code]) => getMatchingLocales([code], [language]).length > 0,
)?.[1] || language;
return (
<DropdownMenu
@@ -77,7 +74,7 @@ export const LanguagePicker = () => {
<Text $isMaterialIcon $color="inherit" $size="xl">
translate
</Text>
{LANGUAGES_ALLOWED[language]}
{currentLanguageLabel}
</Text>
</DropdownMenu>
);
@@ -0,0 +1,45 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/features/auth/api/types';
export interface ChangeUserLanguageParams {
userId: User['id'];
language: User['language'];
}
export const changeUserLanguage = async ({
userId,
language,
}: ChangeUserLanguageParams): Promise<User> => {
const response = await fetchAPI(`users/${userId}/`, {
method: 'PATCH',
body: JSON.stringify({
language,
}),
});
if (!response.ok) {
throw new APIError(
`Failed to change the user language to ${language}`,
await errorCauses(response, {
value: language,
type: 'language',
}),
);
}
return response.json() as Promise<User>;
};
export function useChangeUserLanguage() {
const queryClient = useQueryClient();
return useMutation<User, APIError, ChangeUserLanguageParams>({
mutationFn: changeUserLanguage,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['change-user-language'],
});
},
});
}

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