Compare commits

...

3 Commits

Author SHA1 Message Date
renovate[bot] 9d4d26517b ⬆️(dependencies) update python dependencies 2026-03-23 01:07:12 +00:00
Laurent Paoletti 6dd41e827e (front) projects management UI
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-17 10:49:56 +01:00
Laurent Paoletti f6edb14e1f ♻️(front) clean up zustand stores
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-16 16:41:08 +01:00
116 changed files with 3503 additions and 1003 deletions
+1
View File
@@ -11,6 +11,7 @@ and this project adheres to
### Added
- ✨(back) add projects with custom LLM instructions
- ✨(front) projects management UI
## [0.0.14] - 2026-03-11
+29 -29
View File
@@ -23,50 +23,50 @@ description = "An application to chat with your own AI."
keywords = ["Django", "AI", "Chatbot", "OpenAI", "Pydantic AI", "Conversations"]
license = { file = "LICENSE" }
readme = "README.md"
requires-python = "~=3.13.0"
requires-python = "~=3.14.3"
dependencies = [
"deprecated",
"beautifulsoup4==4.14.2",
"boto3==1.40.73",
"beautifulsoup4==4.14.3",
"boto3==1.42.68",
"Brotli==1.2.0",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.1.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.18",
"django-lasuite[all]==0.0.25",
"django-parler==2.3",
"django-pydantic-field==0.5.4",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.12",
"django==6.0.3",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
"dockerflow==2026.3.4",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"gunicorn==25.1.0",
"jaraco.context>=6.1.0",
"jsonschema==4.25.1",
"langfuse==3.10.0",
"jsonschema==4.26.0",
"langfuse==4.0.0",
"lxml==5.4.0",
"markdown==3.10",
"markdown==3.10.2",
"markitdown==0.0.2",
"mozilla-django-oidc==4.0.1",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"posthog==7.0.0",
"pydantic==2.12.4",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.62.0",
"psycopg[binary]==3.2.12",
"posthog==7.9.12",
"pydantic==2.12.5",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.68.0",
"psycopg[binary]==3.3.3",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
"semchunk==3.2.5",
"sentry-sdk==2.44.0",
"sentry-sdk==2.54.0",
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
"uvicorn==0.41.0",
"whitenoise==6.12.0",
"pypdf==6.8.0",
]
@@ -78,27 +78,27 @@ dependencies = [
[project.optional-dependencies]
dev = [
"dirty-equals==0.10.0",
"dirty-equals==0.11",
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.10.1",
"drf-spectacular-sidecar==2026.3.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.7.0",
"pyfakefs==5.10.2",
"pylint-django==2.6.1",
"ipython==9.11.0",
"pyfakefs==6.1.5",
"pylint-django==2.7.0",
"pylint==3.3.9",
"pylint-pydantic==0.4.1",
"pytest-asyncio==1.3.0",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==9.0.1",
"pytest-django==4.12.0",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"responses==0.26.0",
"respx==0.22.0",
"ruff==0.14.5",
"types-requests==2.32.4.20250913",
"ruff==0.15.6",
"types-requests==2.32.4.20260107",
]
[tool.setuptools]
+496 -461
View File
File diff suppressed because it is too large Load Diff
@@ -846,8 +846,6 @@ if (dsfrThemes.dark) {
dsfrThemes.dark.globals.colors['logo-2'] = '#E78087';
}
console.log(dsfrThemes);
const themes = {
themes: {
default: whiteLabelThemes.light,
+3 -5
View File
@@ -18,13 +18,11 @@
"dependencies": {
"@ai-sdk/react": "1.2.12",
"@ai-sdk/ui-utils": "1.2.11",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/cunningham-react": "^4.2.0",
"@gouvfr-lasuite/cunningham-tokens": "^3.1.0",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.18.7",
"@openfun/cunningham-react": "4.0.0",
"@gouvfr-lasuite/ui-kit": "0.19.10",
"@sentry/nextjs": "9.26.0",
"@shikijs/rehype": "^3.21.0",
"@tanstack/react-query": "5.80.5",
@@ -32,7 +30,6 @@
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"emoji-mart": "5.6.0",
"i18next": "25.2.1",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.3",
@@ -78,6 +75,7 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"sass": "^1.97.3",
"stylelint": "16.20.0",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.0234 1.74219C18.0366 1.74222 18.8012 2.0067 19.3174 2.53516C19.8336 3.05763 20.0918 3.82638 20.0918 4.83984V15.3203C20.0918 15.7987 20.0103 16.1737 19.8467 16.4443C19.683 16.715 19.4848 16.9544 19.252 17.1621C18.8556 17.5396 18.6101 17.9483 18.5156 18.3887C18.4275 18.823 18.4652 19.2263 18.6289 19.5977C18.7925 19.969 19.0688 20.2402 19.459 20.4102C19.6352 20.4857 19.7862 20.599 19.9121 20.75C20.038 20.9074 20.1016 21.0901 20.1016 21.2979C20.1015 21.5431 20.0165 21.751 19.8467 21.9209C19.6831 22.0907 19.4752 22.1757 19.2236 22.1758H6.94824C5.94744 22.1758 5.189 21.914 4.67285 21.3916C4.15673 20.8754 3.89844 20.1136 3.89844 19.1064V4.83984C3.89844 3.82013 4.15672 3.04884 4.67285 2.52637C5.18903 2.0039 5.95431 1.74219 6.96777 1.74219H17.0234ZM16.9951 17.4922C16.907 17.5048 16.8057 17.5107 16.6924 17.5107H7.25977C6.76271 17.5108 6.36957 17.6465 6.08008 17.917C5.79681 18.1877 5.65527 18.5343 5.65527 18.9561C5.65534 19.4028 5.78403 19.7582 6.04199 20.0225C6.30008 20.2868 6.68098 20.4189 7.18457 20.4189H17.0898C16.8381 19.9909 16.7025 19.5191 16.6836 19.0029C16.6647 18.493 16.7685 17.9895 16.9951 17.4922ZM6.81641 3.31934C6.6654 3.31934 6.53319 3.37606 6.41992 3.48926C6.30662 3.59627 6.25 3.73184 6.25 3.89551V15.415C6.25004 15.5723 6.30666 15.708 6.41992 15.8213C6.53315 15.9344 6.66549 15.9912 6.81641 15.9912C6.97369 15.9912 7.1094 15.9345 7.22266 15.8213C7.33592 15.708 7.39253 15.5723 7.39258 15.415V3.89551C7.39258 3.73194 7.33583 3.59624 7.22266 3.48926C7.1094 3.376 6.97369 3.31938 6.81641 3.31934Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.6259 1.87573C16.5271 1.87574 17.2093 2.11081 17.6721 2.57969C18.1347 3.04853 18.3658 3.7364 18.3659 4.64344V20.5735C18.3658 20.9814 18.2598 21.3014 18.0467 21.5328C17.8397 21.7703 17.5559 21.8889 17.1966 21.8889C16.9471 21.8889 16.719 21.8125 16.5121 21.6604C16.305 21.5143 16.0032 21.2495 15.6074 20.8658L12.0728 17.3766C12.0241 17.3218 11.9722 17.3218 11.9174 17.3766L8.3921 20.8658C7.99629 21.2495 7.69129 21.5143 7.47816 21.6604C7.27115 21.8126 7.04551 21.8889 6.80196 21.8889C6.43678 21.8888 6.14733 21.7702 5.93428 21.5328C5.72726 21.3014 5.6244 20.9815 5.62439 20.5735V4.64344C5.62442 3.73636 5.85554 3.04853 6.31817 2.57969C6.78096 2.1108 7.46312 1.87573 8.36435 1.87573H15.6259Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 804 B

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 3.97363C12.791 3.97363 13.5222 3.97923 14.1934 3.99121C14.8707 4.0032 15.4464 4.01815 15.9199 4.03613C16.3994 4.05411 16.7471 4.07843 16.9629 4.1084C17.5802 4.19232 18.1224 4.39331 18.5898 4.71094C19.0633 5.02261 19.426 5.44228 19.6777 5.96973C19.7796 6.19149 19.9059 6.51222 20.0557 6.93164C20.2114 7.35103 20.3639 7.82439 20.5137 8.35156C20.6695 8.87897 20.8013 9.41276 20.9092 9.95215L21.71 10.9502C21.9916 11.3157 22.2132 11.6692 22.375 12.0107C22.5428 12.3524 22.6625 12.7216 22.7344 13.1172C22.8063 13.5126 22.8428 13.9738 22.8428 14.501V19.8242C22.8427 20.1656 22.7257 20.4529 22.4922 20.6865C22.2584 20.9203 21.9705 21.0371 21.6289 21.0371H20.541C20.1994 21.0371 19.9115 20.9203 19.6777 20.6865C19.4503 20.4529 19.336 20.1655 19.3359 19.8242V18.2637C19.2742 18.2677 19.2114 18.2734 19.1475 18.2773C18.4703 18.3193 17.7242 18.3586 16.9092 18.3945C16.1002 18.4245 15.2699 18.4478 14.4189 18.4658C13.5738 18.4838 12.7672 18.4932 12 18.4932C11.2328 18.4932 10.4234 18.4838 9.57227 18.4658C8.72725 18.4478 7.89709 18.4245 7.08203 18.3945C6.27294 18.3586 5.52979 18.3193 4.85254 18.2773C4.79159 18.2736 4.73176 18.2675 4.67285 18.2637V19.8242C4.67277 20.1655 4.55285 20.4529 4.31348 20.6865C4.07978 20.9202 3.79173 21.0371 3.4502 21.0371H2.37988C2.03233 21.0371 1.74153 20.9202 1.50781 20.6865C1.27434 20.4529 1.15731 20.1656 1.15723 19.8242V14.501C1.15725 13.9738 1.19375 13.5126 1.26562 13.1172C1.33755 12.7216 1.45438 12.3524 1.61621 12.0107C1.784 11.6692 2.00847 11.3156 2.29004 10.9502L3.08105 9.95215C3.19494 9.41271 3.33049 8.87901 3.48633 8.35156C3.64209 7.82439 3.79456 7.35103 3.94434 6.93164C4.09411 6.51226 4.2232 6.19148 4.33105 5.96973C4.5768 5.44228 4.93386 5.01982 5.40137 4.70215C5.86883 4.38454 6.41385 4.18632 7.03711 4.1084C7.24689 4.08442 7.59179 4.0629 8.07129 4.04492C8.55065 4.02096 9.12585 4.0032 9.79688 3.99121C10.4682 3.97922 11.2028 3.97363 12 3.97363ZM5.41895 12.9824C4.98747 12.9824 4.62752 13.1255 4.33984 13.4131C4.05815 13.7008 3.917 14.0579 3.91699 14.4834C3.91699 14.909 4.05814 15.2688 4.33984 15.5625C4.62754 15.8502 4.9874 15.9941 5.41895 15.9941C5.8503 15.9941 6.20664 15.85 6.48828 15.5625C6.77598 15.2688 6.91992 14.909 6.91992 14.4834C6.91991 14.0579 6.77597 13.7008 6.48828 13.4131C6.20666 13.1257 5.85022 12.9824 5.41895 12.9824ZM18.5811 12.9824C18.1555 12.9824 17.7984 13.1254 17.5107 13.4131C17.2231 13.7008 17.0801 14.0579 17.0801 14.4834C17.0801 14.9089 17.2231 15.2688 17.5107 15.5625C17.7984 15.8502 18.1555 15.9941 18.5811 15.9941C19.0066 15.9941 19.3637 15.8502 19.6514 15.5625C19.939 15.2688 20.083 14.9089 20.083 14.4834C20.083 14.0579 19.9391 13.7008 19.6514 13.4131C19.3637 13.1254 19.0066 12.9824 18.5811 12.9824ZM9.57227 13.4404C9.24873 13.4405 8.98498 13.5424 8.78125 13.7461C8.58347 13.9439 8.48439 14.2047 8.48438 14.5283C8.48438 14.8459 8.5835 15.1068 8.78125 15.3105C8.98498 15.5083 9.24873 15.6074 9.57227 15.6074H14.4277C14.7452 15.6074 15.0025 15.5081 15.2002 15.3105C15.404 15.1068 15.5059 14.846 15.5059 14.5283C15.5058 14.2047 15.404 13.9439 15.2002 13.7461C15.0025 13.5426 14.7451 13.4405 14.4277 13.4404H9.57227ZM12 5.67285C10.6214 5.67285 9.53349 5.68501 8.73633 5.70898C7.94543 5.73295 7.38814 5.76568 7.06445 5.80762C6.79493 5.84954 6.57598 5.92727 6.4082 6.04102C6.24038 6.1489 6.08987 6.31996 5.95801 6.55371C5.87411 6.71554 5.77597 6.94367 5.66211 7.2373C5.55427 7.52489 5.44573 7.83918 5.33789 8.18066C5.23601 8.51626 5.15564 8.83135 5.0957 9.125C5.04775 9.3168 5.06271 9.45795 5.14062 9.54785C5.22455 9.63748 5.36507 9.67599 5.5625 9.66406C6.02997 9.64009 6.55162 9.61675 7.12695 9.59277C7.70235 9.5628 8.38279 9.53849 9.16797 9.52051C9.95314 9.50253 10.8972 9.49414 12 9.49414C13.0968 9.49414 14.0381 9.50253 14.8232 9.52051C15.6143 9.53849 16.2977 9.56281 16.873 9.59277C17.4544 9.61675 17.976 9.64009 18.4375 9.66406C18.6409 9.67599 18.7814 9.63747 18.8594 9.54785C18.9433 9.45795 18.962 9.31679 18.9141 9.125C18.8421 8.83131 18.7552 8.51352 18.6533 8.17188C18.5515 7.83037 18.4457 7.51612 18.3379 7.22852C18.23 6.93482 18.1281 6.70955 18.0322 6.55371C17.9004 6.32007 17.7536 6.14887 17.5918 6.04102C17.43 5.92727 17.2111 5.84954 16.9355 5.80762C16.6119 5.75969 16.0517 5.72696 15.2549 5.70898C14.4577 5.68501 13.3726 5.67285 12 5.67285Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,5 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.39578 8.98665C6.02586 8.98669 6.5045 9.14711 6.83081 9.46779C7.16257 9.78285 7.32866 10.2415 7.3287 10.8433V19.9997C7.32864 20.5959 7.16261 21.0518 6.83081 21.3668C6.5045 21.6875 6.02586 21.8479 5.39578 21.848H2.99847C2.36832 21.848 1.88702 21.6875 1.55506 21.3668C1.22881 21.0518 1.06561 20.596 1.06555 19.9997L1.06555 10.8433C1.06559 10.2414 1.22886 9.78285 1.55506 9.46779C1.88702 9.14708 2.36832 8.98667 2.99847 8.98665H5.39578Z" fill="currentColor"/>
<path d="M13.1945 5.561C13.8245 5.56105 14.3023 5.7215 14.6286 6.04214C14.9605 6.36286 15.1274 6.82393 15.1274 7.42599V19.9997C15.1273 20.596 14.9605 21.0518 14.6286 21.3668C14.3023 21.6873 13.8243 21.8479 13.1945 21.848H10.7972C10.167 21.848 9.68572 21.6876 9.35375 21.3668C9.02748 21.0518 8.8643 20.596 8.86424 19.9997V7.42599C8.86424 6.82409 9.02758 6.36285 9.35375 6.04214C9.68572 5.72142 10.167 5.56101 10.7972 5.561H13.1945Z" fill="currentColor"/>
<path d="M20.9932 2.1521C21.6288 2.15216 22.1093 2.31257 22.4356 2.63324C22.7676 2.95396 22.9344 3.41228 22.9344 4.00871V19.9997C22.9344 20.596 22.7675 21.0518 22.4356 21.3668C22.1093 21.6873 21.6286 21.8479 20.9932 21.848H18.5958C17.9713 21.848 17.4928 21.6876 17.1608 21.3668C16.8346 21.0518 16.6714 20.596 16.6713 19.9997V4.00871C16.6713 3.41248 16.8347 2.95394 17.1608 2.63324C17.4928 2.31251 17.9713 2.1521 18.5958 2.1521H20.9932Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.44933 20.0391C5.61068 20.0391 4.98629 19.834 4.57615 19.4239C4.16601 19.0198 3.96094 18.3954 3.96094 17.5507V15.5857C3.96094 15.4143 3.90584 15.2704 3.79566 15.1541L2.39995 13.7584C1.80005 13.1646 1.50009 12.58 1.50009 12.0046C1.50009 11.4292 1.80005 10.8415 2.39995 10.2416L3.79566 8.84589C3.90584 8.72959 3.96094 8.58879 3.96094 8.42351V6.44933C3.96094 5.59844 4.16295 4.97098 4.56696 4.56696C4.97711 4.16295 5.60456 3.96094 6.44933 3.96094H8.42351C8.59491 3.96094 8.73877 3.90278 8.85508 3.78647L10.2508 2.39995C10.8446 1.80005 11.4292 1.50009 12.0046 1.50009C12.58 1.49397 13.1646 1.79392 13.7584 2.39995L15.1541 3.78647C15.2704 3.90278 15.4143 3.96094 15.5857 3.96094H17.5507C18.4016 3.96094 19.029 4.16601 19.433 4.57615C19.8371 4.98629 20.0391 5.61068 20.0391 6.44933V8.42351C20.0391 8.58879 20.1003 8.72959 20.2227 8.84589L21.6092 10.2416C22.203 10.8415 22.4999 11.4292 22.4999 12.0046C22.4999 12.58 22.203 13.1646 21.6092 13.7584L20.2227 15.1541C20.1003 15.2704 20.0391 15.4143 20.0391 15.5857V17.5507C20.0391 18.4016 19.834 19.029 19.4239 19.433C19.0198 19.8371 18.3954 20.0391 17.5507 20.0391H15.5857C15.4143 20.0391 15.2704 20.0972 15.1541 20.2135L13.7584 21.6092C13.1646 22.203 12.58 22.4999 12.0046 22.4999C11.4292 22.506 10.8446 22.2091 10.2508 21.6092L8.85508 20.2135C8.73877 20.0972 8.59491 20.0391 8.42351 20.0391H6.44933ZM10.9854 16.5223C11.1568 16.5223 11.3129 16.4825 11.4537 16.4029C11.6006 16.3233 11.7291 16.2039 11.8393 16.0448L16.1366 9.34174C16.1978 9.24379 16.2529 9.13973 16.3019 9.02954C16.3509 8.91323 16.3753 8.80304 16.3753 8.69898C16.3753 8.46024 16.2835 8.26741 16.0999 8.1205C15.9224 7.97358 15.7203 7.90012 15.4938 7.90012C15.1939 7.90012 14.946 8.05928 14.7501 8.3776L10.9486 14.4563L9.18564 12.2158C9.06321 12.0627 8.94384 11.9556 8.82753 11.8944C8.71122 11.8332 8.57961 11.8026 8.43269 11.8026C8.20008 11.8026 8.00113 11.8883 7.83585 12.0597C7.67669 12.225 7.59711 12.4239 7.59711 12.6565C7.59711 12.7728 7.61853 12.8861 7.66138 12.9963C7.70423 13.1065 7.76545 13.2136 7.84503 13.3177L10.0947 16.054C10.2294 16.2192 10.3671 16.3386 10.5079 16.4121C10.6487 16.4855 10.8078 16.5223 10.9854 16.5223Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.54004C13.4245 1.54005 14.7604 1.80664 16.0068 2.34082C17.2532 2.875 18.348 3.61778 19.291 4.56738C20.2339 5.51037 20.9727 6.60529 21.5068 7.85156C22.0475 9.09783 22.3174 10.4331 22.3174 11.8574C22.3174 13.2819 22.0476 14.6178 21.5068 15.8643C20.9727 17.1041 20.234 18.1956 19.291 19.1387C18.348 20.0882 17.2531 20.8301 16.0068 21.3643C14.7604 21.905 13.4245 22.1758 12 22.1758C10.5755 22.1758 9.23961 21.905 7.99316 21.3643C6.74686 20.8301 5.64881 20.0883 4.69922 19.1387C3.75617 18.1956 3.01759 17.1041 2.4834 15.8643C1.94921 14.6178 1.68164 13.2819 1.68164 11.8574C1.68167 10.4331 1.94932 9.09786 2.4834 7.85156C3.01754 6.60524 3.75627 5.5104 4.69922 4.56738C5.64885 3.61775 6.74679 2.87501 7.99316 2.34082C9.23961 1.80663 10.5755 1.54004 12 1.54004ZM13.2861 7.28711C12.5212 7.28711 11.8121 7.40933 11.1592 7.65332C10.5129 7.89733 9.96506 8.2671 9.5166 8.76172C9.06839 9.25622 8.7647 9.88571 8.60645 10.6504H7.58789C7.50217 10.6504 7.42947 10.6809 7.37012 10.7402C7.31095 10.7995 7.28125 10.8756 7.28125 10.9678C7.28135 11.0532 7.31105 11.1254 7.37012 11.1846C7.42947 11.2439 7.50217 11.2744 7.58789 11.2744H8.51758C8.511 11.36 8.50463 11.4489 8.49805 11.541V12.1055C8.50463 12.1976 8.511 12.2865 8.51758 12.3721H7.58789C7.50217 12.3721 7.42947 12.4016 7.37012 12.4609C7.3108 12.5203 7.28128 12.5962 7.28125 12.6885C7.28125 12.7676 7.31082 12.8403 7.37012 12.9062C7.42947 12.9656 7.50217 12.9951 7.58789 12.9951H8.61719C8.84141 14.0767 9.36513 14.9079 10.1895 15.4883C11.0138 16.062 12.0399 16.3486 13.2666 16.3486C13.4378 16.3486 13.6123 16.3391 13.79 16.3193C13.9746 16.2996 14.1465 16.2668 14.3047 16.2207C14.4695 16.1679 14.6086 16.0921 14.7207 15.9932C14.8328 15.8877 14.8886 15.7422 14.8887 15.5576C14.8887 15.3466 14.8192 15.1949 14.6807 15.1025C14.5422 15.0036 14.3673 14.9541 14.1562 14.9541C14.0377 14.9541 13.9385 14.9605 13.8594 14.9736C13.7868 14.9868 13.6749 14.9932 13.5234 14.9932C12.6727 14.9932 11.97 14.8256 11.416 14.4893C10.8621 14.1463 10.4859 13.648 10.2881 12.9951H13.2559C13.3415 12.9951 13.4143 12.9655 13.4736 12.9062C13.5396 12.8403 13.5732 12.7676 13.5732 12.6885C13.5732 12.5962 13.5395 12.5203 13.4736 12.4609C13.4143 12.4017 13.3415 12.3721 13.2559 12.3721H10.1504C10.1438 12.2865 10.1365 12.1976 10.1299 12.1055V11.541C10.1365 11.4488 10.1438 11.36 10.1504 11.2744H13.2559C13.3416 11.2744 13.4143 11.2439 13.4736 11.1846C13.5394 11.1253 13.5731 11.0533 13.5732 10.9678C13.5732 10.8755 13.5396 10.7996 13.4736 10.7402C13.4143 10.6809 13.3416 10.6504 13.2559 10.6504H10.2881C10.4794 10.0042 10.846 9.50989 11.3867 9.16699C11.9341 8.82407 12.6463 8.65234 13.5234 8.65234C13.6487 8.65235 13.7678 8.65869 13.8799 8.67188C13.9917 8.67845 14.087 8.68261 14.166 8.68262C14.3769 8.68262 14.5488 8.63297 14.6807 8.53418C14.8191 8.43533 14.8886 8.28014 14.8887 8.06934C14.8887 7.89791 14.8359 7.75883 14.7305 7.65332C14.6315 7.54121 14.4893 7.45584 14.3047 7.39648C14.1596 7.35694 14.0015 7.33057 13.8301 7.31738C13.6587 7.2976 13.4773 7.28712 13.2861 7.28711Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.10682 21.8756C6.10841 21.8756 5.35495 21.6182 4.84644 21.1035C4.33793 20.5888 4.08368 19.8292 4.08368 18.8245V4.78786C4.08368 3.78945 4.33793 3.03289 4.84644 2.51818C5.35495 1.99727 6.10841 1.73682 7.10682 1.73682H11.3485V8.76911C11.3485 9.92256 11.9252 10.4993 13.0787 10.4993H20.0366V18.8245C20.0366 19.823 19.7823 20.5795 19.2738 21.0942C18.7653 21.6151 18.0118 21.8756 17.0134 21.8756H7.10682ZM13.2461 9.14119C12.8927 9.14119 12.7159 8.96445 12.7159 8.61098V1.82984C12.9268 1.85464 13.1376 1.94146 13.3484 2.09029C13.5655 2.23912 13.7887 2.43136 14.0182 2.66701L19.0971 7.82031C19.3389 8.06836 19.5312 8.29781 19.6738 8.50866C19.8226 8.7195 19.9094 8.93035 19.9342 9.14119H13.2461Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 801 B

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.80179 17.4336V10.0689H22.1983V17.4336C22.1983 18.3616 21.9737 19.0562 21.5247 19.5172C21.0756 19.9843 20.4499 20.2178 19.6476 20.2178H4.62191C3.68187 20.2178 2.97534 19.9843 2.50233 19.5172C2.0353 19.0562 1.80179 18.3616 1.80179 17.4336ZM1.80179 8.89239V6.48541C1.80179 5.58129 2.01734 4.9047 2.44844 4.45564C2.87954 4.00658 3.49027 3.78204 4.28062 3.78204H6.84028C7.13367 3.78204 7.38814 3.803 7.60369 3.84491C7.82523 3.88682 8.02581 3.95568 8.20543 4.05148C8.39105 4.14728 8.58265 4.27901 8.78024 4.44666L9.32809 4.89572C9.55562 5.08732 9.77416 5.22503 9.98373 5.30886C10.1933 5.3867 10.4508 5.42562 10.7561 5.42562H19.3781C20.3122 5.42562 21.0157 5.65913 21.4887 6.12615C21.9617 6.59318 22.1983 7.28773 22.1983 8.20981V8.89239H1.80179Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 854 B

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.6875 11.1467C17.2283 11.3776 17.8056 11.4931 18.4193 11.4931C19.0208 11.4931 19.592 11.3776 20.1328 11.1467C20.6736 10.9097 21.1476 10.5847 21.5547 10.1715C21.9679 9.75827 22.2899 9.28432 22.5208 8.74959C22.7578 8.2088 22.8763 7.63458 22.8763 7.02694C22.8763 6.20662 22.6758 5.45923 22.2747 4.78475C21.8737 4.11027 21.3329 3.57251 20.6523 3.17147C19.9779 2.76435 19.2335 2.56079 18.4193 2.56079C17.599 2.56079 16.8516 2.76435 16.1771 3.17147C15.5026 3.57251 14.9618 4.11027 14.5547 4.78475C14.1536 5.45923 13.9531 6.20662 13.9531 7.02694C13.9531 7.63458 14.0686 8.2088 14.2995 8.74959C14.5365 9.29039 14.8585 9.76739 15.2656 10.1806C15.6788 10.5877 16.1528 10.9097 16.6875 11.1467ZM18.8659 9.70662C18.7565 9.816 18.6107 9.87069 18.4284 9.87069C18.24 9.87069 18.0881 9.816 17.9727 9.70662C17.8633 9.59117 17.8086 9.4423 17.8086 9.26001V7.63761H16.1953C16.013 7.63761 15.8642 7.58293 15.7487 7.47355C15.6393 7.3581 15.5846 7.20923 15.5846 7.02694C15.5846 6.83857 15.6393 6.6897 15.7487 6.58032C15.8642 6.47095 16.013 6.41626 16.1953 6.41626H17.8086V4.80298C17.8086 4.62069 17.8633 4.47485 17.9727 4.36548C18.0881 4.25003 18.24 4.1923 18.4284 4.1923C18.6107 4.1923 18.7565 4.25003 18.8659 4.36548C18.9813 4.47485 19.0391 4.62069 19.0391 4.80298V6.41626H20.6523C20.8346 6.41626 20.9805 6.47095 21.0898 6.58032C21.2053 6.6897 21.263 6.83857 21.263 7.02694C21.263 7.20923 21.2053 7.3581 21.0898 7.47355C20.9805 7.58293 20.8346 7.63761 20.6523 7.63761H19.0391V9.26001C19.0391 9.4423 18.9813 9.59117 18.8659 9.70662Z" fill="#222631"/>
<path d="M11.0156 5.00098H13.2079C12.9971 5.54224 12.8683 6.12447 12.8367 6.73242H10.5586C10.2539 6.73242 9.9873 6.71191 9.75879 6.6709C9.53613 6.62988 9.32812 6.5625 9.13477 6.46875C8.94727 6.36914 8.75391 6.2373 8.55469 6.07324L8.05371 5.66016C7.80762 5.46094 7.57617 5.32031 7.35938 5.23828C7.14844 5.15039 6.8877 5.10645 6.57715 5.10645H4.88086C4.48828 5.10645 4.18652 5.21191 3.97559 5.42285C3.76465 5.62793 3.65918 5.93262 3.65918 6.33691V8.52539H13.0324C13.1837 9.07028 13.4156 9.58166 13.7144 10.0459H3.65918V16.752C3.65918 17.1797 3.76465 17.499 3.97559 17.71C4.19238 17.9209 4.50586 18.0264 4.91602 18.0264H19.0752C19.4795 18.0264 19.793 17.9209 20.0156 17.71C20.2383 17.499 20.3496 17.1826 20.3496 16.7607V12.2683C20.9865 12.0331 21.5688 11.6851 22.0723 11.2485V16.9014C22.0723 17.8389 21.8379 18.5479 21.3691 19.0283C20.9004 19.5146 20.2471 19.7578 19.4092 19.7578H4.81055C3.86133 19.7578 3.14355 19.5146 2.65723 19.0283C2.1709 18.5479 1.92773 17.8389 1.92773 16.9014V6.1875C1.92773 5.26172 2.15625 4.56445 2.61328 4.0957C3.07617 3.62109 3.71777 3.38379 4.53809 3.38379H7.02539C7.33008 3.38379 7.59375 3.4043 7.81641 3.44531C8.03906 3.48633 8.24414 3.55664 8.43164 3.65625C8.61914 3.75 8.81543 3.88184 9.02051 4.05176L9.52148 4.46484C9.76758 4.6582 9.99902 4.7959 10.2158 4.87793C10.4326 4.95996 10.6992 5.00098 11.0156 5.00098Z" fill="#222631"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.1604 21.75C10.8912 21.75 10.6618 21.6735 10.4721 21.5206C10.2825 21.3738 10.154 21.1658 10.0867 20.8966L9.68925 19.2034C9.65105 19.0407 9.5318 18.9096 9.37524 18.851C9.30279 18.8239 9.23101 18.7961 9.15988 18.7676C9.09695 18.744 9.03514 18.7199 8.97446 18.6952C8.82514 18.6343 8.65532 18.6434 8.51752 18.7271L7.03094 19.6302C6.80459 19.7709 6.56906 19.8291 6.32435 19.8046C6.07965 19.7862 5.86247 19.6822 5.67282 19.4926L4.50741 18.3272C4.31776 18.1375 4.21071 17.9173 4.18624 17.6665C4.16176 17.4095 4.22294 17.1679 4.36976 16.9415L5.27375 15.4696C5.35707 15.3339 5.36889 15.1661 5.3054 15.0201C5.27799 14.957 5.25058 14.894 5.22318 14.8309C5.19937 14.7738 5.17695 14.7167 5.15592 14.6595C5.09541 14.4951 4.96195 14.3654 4.79153 14.3248L3.10341 13.9225C2.83424 13.8613 2.62318 13.7359 2.47024 13.5462C2.32341 13.3566 2.25 13.1241 2.25 12.8488V11.2062C2.25 10.9371 2.32341 10.7076 2.47024 10.518C2.62318 10.3284 2.83424 10.2029 3.10341 10.1418L4.77534 9.73956C4.94459 9.69884 5.07731 9.57024 5.13624 9.40645C5.16096 9.33775 5.18688 9.27086 5.214 9.20576C5.24006 9.14322 5.26557 9.08234 5.29052 9.02313C5.35151 8.87837 5.34018 8.7131 5.25894 8.57866L4.35141 7.07682C4.21071 6.84435 4.14953 6.60882 4.16788 6.37024C4.18624 6.12553 4.29329 5.90835 4.48906 5.71871L5.67282 4.54412C5.86247 4.35447 6.07353 4.24741 6.306 4.22294C6.53847 4.19235 6.76788 4.24435 6.99424 4.37894L8.49778 5.30175C8.64132 5.38985 8.81959 5.39626 8.97383 5.32862C9.03426 5.30212 9.09628 5.27615 9.15988 5.25071C9.2309 5.2223 9.30259 5.19455 9.37493 5.16746C9.53166 5.10877 9.65098 4.97751 9.68905 4.81454L10.0867 3.11259C10.154 2.84953 10.2825 2.64153 10.4721 2.48859C10.6618 2.32953 10.8912 2.25 11.1604 2.25H12.8396C13.1149 2.25 13.3444 2.32953 13.5279 2.48859C13.7175 2.64153 13.846 2.84953 13.9133 3.11259L14.3105 4.82855C14.3488 4.99389 14.4708 5.12615 14.6297 5.18582C14.7018 5.2129 14.7719 5.24065 14.8401 5.26906C14.8989 5.29022 14.9559 5.31229 15.011 5.33526C15.1698 5.40135 15.352 5.39733 15.4984 5.30717L17.0058 4.37894C17.2321 4.24435 17.4585 4.19541 17.6848 4.23212C17.9173 4.26271 18.1284 4.36365 18.318 4.53494L19.5201 5.71871C19.7159 5.90835 19.8199 6.12553 19.8321 6.37024C19.8505 6.60882 19.7893 6.84435 19.6486 7.07682L18.7334 8.57814C18.6512 8.71283 18.6395 8.87886 18.7008 9.02424C18.7256 9.0831 18.7509 9.14361 18.7768 9.20576C18.8055 9.27465 18.8336 9.34556 18.8609 9.41849C18.9201 9.57616 19.0507 9.6976 19.2145 9.73682L20.9058 10.1418C21.1688 10.2029 21.3738 10.3284 21.5206 10.518C21.6735 10.7076 21.75 10.9371 21.75 11.2062V12.8488C21.75 13.1241 21.6735 13.3566 21.5206 13.5462C21.3738 13.7359 21.1688 13.8613 20.9058 13.9225L19.1969 14.3278C19.0327 14.3668 18.901 14.4881 18.8442 14.647C18.8223 14.7083 18.7998 14.7696 18.7768 14.8309C18.7544 14.8883 18.7304 14.9457 18.7049 15.0031C18.635 15.1604 18.6397 15.3428 18.7304 15.4891L19.6302 16.9415C19.7771 17.1679 19.8382 17.4095 19.8138 17.6665C19.7954 17.9173 19.6914 18.1375 19.5018 18.3272L18.3272 19.4926C18.1375 19.6822 17.9204 19.7862 17.6756 19.8046C17.4309 19.8291 17.1954 19.7709 16.9691 19.6302L15.4825 18.7271C15.3447 18.6434 15.1749 18.6345 15.0251 18.6941C14.9621 18.7192 14.8974 18.7437 14.8309 18.7676C14.7634 18.7958 14.6946 18.8233 14.6244 18.8501C14.4683 18.9099 14.349 19.0407 14.3107 19.2034L13.9133 20.8966C13.846 21.1658 13.7175 21.3738 13.5279 21.5206C13.3444 21.6735 13.1149 21.75 12.8396 21.75H11.1604ZM12.0046 15.2164C12.5919 15.2164 13.1272 15.0726 13.6105 14.7851C14.0999 14.4975 14.4884 14.1091 14.7759 13.6196C15.0634 13.1302 15.2072 12.5919 15.2072 12.0046C15.2072 11.4173 15.0634 10.8789 14.7759 10.3895C14.4884 9.90012 14.0999 9.51165 13.6105 9.22412C13.1272 8.93659 12.5919 8.79282 12.0046 8.79282C11.4112 8.79282 10.8698 8.93659 10.3804 9.22412C9.89706 9.51165 9.51165 9.90012 9.22412 10.3895C8.93659 10.8789 8.79282 11.4173 8.79282 12.0046C8.79282 12.5919 8.93659 13.1302 9.22412 13.6196C9.51165 14.1091 9.89706 14.4975 10.3804 14.7851C10.8698 15.0726 11.4112 15.2164 12.0046 15.2164Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.8196 3.27502C12.6849 3.20798 13.6964 3.68687 14.0579 4.51721C14.0768 4.56013 14.1142 4.58756 14.1526 4.59142C14.8334 4.65904 15.3735 4.76098 15.8938 4.94592C16.414 5.13089 16.9109 5.39715 17.5061 5.78869C17.7397 5.94233 18.0965 6.18728 18.4631 6.36389C18.6461 6.45197 18.8276 6.5207 18.9934 6.55334C19.16 6.58606 19.3027 6.58097 19.4143 6.53088L19.4153 6.5299C19.6268 6.43761 19.7781 6.25157 19.9504 6.04357C20.116 5.8438 20.3026 5.62243 20.5784 5.52502C20.9848 5.3817 21.3823 5.555 21.6213 5.8424C21.8598 6.12954 21.9569 6.54997 21.7444 6.9176C21.4197 7.47932 20.7654 8.09265 20.1252 8.36974C20.0592 8.39859 20.0302 8.47454 20.0549 8.53283C20.1459 8.74738 20.2438 9.00351 20.3196 9.1881L20.9485 10.6988L22.9026 15.3326L22.9036 15.3336C22.9308 15.3995 22.9918 15.5488 23.0461 15.6969C23.0732 15.7707 23.099 15.8458 23.1184 15.9108C23.1366 15.9719 23.153 16.0369 23.1536 16.0836L23.1438 16.4108C23.091 17.1792 22.823 17.9715 22.3977 18.6578C21.9117 19.442 21.2157 20.0959 20.3889 20.4166C19.312 20.8343 17.9294 20.828 16.886 20.4C15.2308 19.7206 14.2216 17.8826 14.1331 16.1715V16.1686C14.1324 16.1406 14.1381 16.1077 14.1448 16.0778C14.1519 16.0459 14.1624 16.0086 14.1751 15.9674C14.2006 15.8844 14.2367 15.7799 14.2805 15.6627C14.3683 15.4281 14.4872 15.1351 14.6106 14.8395C14.8558 14.252 15.1268 13.6368 15.2014 13.4596L17.4397 8.13537C17.459 8.08941 17.4414 8.03478 17.3977 8.00842C16.8619 7.68751 16.355 7.35033 15.8245 7.08068C15.2967 6.81248 14.755 6.61682 14.1614 6.58264C14.1284 6.58074 14.0935 6.59878 14.0725 6.63342C14.0007 6.75264 13.9221 6.86816 13.8381 6.97912C13.3992 7.55026 12.8809 7.78055 12.1965 7.87267L12.1858 7.87365C11.6562 7.89164 11.2267 7.79582 10.8626 7.58361C10.4993 7.37182 10.2092 7.04801 9.94751 6.62658C9.92625 6.59251 9.88908 6.57499 9.85376 6.57775C9.11593 6.63917 8.69868 6.80402 8.16431 7.1051L7.57447 7.45275C7.26287 7.63733 6.94962 7.82036 6.63599 8.00158C6.59074 8.02791 6.57064 8.08757 6.59302 8.14025C6.64148 8.25434 6.69548 8.38995 6.74634 8.51818C6.79738 8.64689 6.84513 8.76886 6.88306 8.859L7.74048 10.8922C8.40108 12.459 9.06497 14.0318 9.72095 15.6012C9.74364 15.6555 9.78376 15.7647 9.81861 15.8727C9.83608 15.9269 9.85315 15.9827 9.86548 16.0319C9.87714 16.0784 9.8875 16.128 9.88794 16.1666V16.1686C9.88022 16.809 9.66181 17.4977 9.42017 18.0631C8.55729 20.0815 6.63448 21.0118 4.50904 20.6578C4.13575 20.5956 3.87503 20.527 3.52076 20.3707C2.37858 19.8667 1.57118 18.858 1.16138 17.7067C0.997368 17.2482 0.823369 16.6608 0.849857 16.1363V16.1276L0.882083 15.9733C0.925715 15.805 1.00191 15.5995 1.08228 15.401C1.19031 15.1341 1.313 14.8621 1.38599 14.6901L2.01392 13.1988C2.64244 11.7048 3.30964 10.0233 3.97681 8.53478C4.00042 8.48207 3.97534 8.41174 3.91333 8.38244C3.48923 8.18314 2.92063 7.7873 2.54908 7.32971C2.36342 7.10094 2.22092 6.84879 2.17603 6.5924C2.13038 6.33098 2.18753 6.06977 2.39087 5.83557C2.5753 5.62278 2.83915 5.49497 3.12036 5.48107H3.20728C3.40362 5.4918 3.56612 5.57065 3.7063 5.67834C3.8631 5.79888 3.99772 5.96033 4.12232 6.10607C4.38331 6.41132 4.60168 6.64005 4.95044 6.57287C5.26374 6.51234 5.6192 6.33846 5.97681 6.12951C6.15466 6.02559 6.33044 5.91378 6.50025 5.80627C6.66932 5.69922 6.83331 5.59579 6.98365 5.50744C7.30179 5.31868 7.63196 5.15044 7.97193 5.00451L8.23072 4.90197C8.80917 4.68761 9.23285 4.6409 9.8606 4.60217C9.89592 4.59991 9.92977 4.5779 9.94849 4.54357C10.3432 3.81016 10.9662 3.36697 11.8167 3.27502H11.8196ZM2.93384 16.109L7.79029 16.1119C7.4586 15.3141 5.79821 11.326 5.38306 10.3639L2.93384 16.109ZM18.6184 10.3707C17.8845 12.2332 16.5414 15.4101 16.2444 16.111L21.0842 16.1051L18.6184 10.3707Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0046 0.599976C12.8309 0.599976 13.6052 0.756058 14.3275 1.06822C15.0559 1.37427 15.6955 1.80273 16.2464 2.35361C16.7972 2.89837 17.2257 3.53494 17.5318 4.26333C17.8439 4.98559 18 5.75988 18 6.5862C18 7.82874 17.6297 8.95499 16.8891 9.96493C16.1546 10.9688 15.0987 11.7614 13.7215 12.3429L15.9985 14.6291C16.127 14.7637 16.1913 14.9137 16.1913 15.0789C16.1974 15.2442 16.1362 15.3911 16.0077 15.5196L13.6664 17.8517L15.3466 19.5135C15.469 19.6421 15.5302 19.789 15.5302 19.9542C15.5363 20.1256 15.4751 20.2756 15.3466 20.4041L12.4453 23.3054C12.3229 23.4278 12.179 23.4829 12.0138 23.4707C11.8485 23.4584 11.7169 23.4033 11.619 23.3054L10.0306 21.7538C9.93267 21.6558 9.85922 21.5487 9.81025 21.4324C9.76741 21.3222 9.74598 21.1998 9.74598 21.0652V12.1593C8.98087 11.8471 8.31676 11.4125 7.75363 10.8555C7.19663 10.2985 6.76511 9.65277 6.45907 8.91826C6.15302 8.18375 6 7.4064 6 6.5862C6 5.75376 6.15302 4.97641 6.45907 4.25415C6.77123 3.52576 7.19969 2.88919 7.74445 2.34443C8.29533 1.79355 8.93191 1.36509 9.65417 1.05904C10.3826 0.752998 11.166 0.599976 12.0046 0.599976ZM12.0046 6.02614C12.4269 6.02614 12.7881 5.87618 13.088 5.57626C13.3879 5.27633 13.5379 4.9152 13.5379 4.49286C13.5379 4.0644 13.3879 3.70327 13.088 3.40946C12.7881 3.10954 12.4269 2.95958 12.0046 2.95958C11.57 2.95958 11.2058 3.10954 10.912 3.40946C10.6182 3.70327 10.4713 4.0644 10.4713 4.49286C10.4713 4.9152 10.6182 5.27633 10.912 5.57626C11.2119 5.87618 11.5761 6.02614 12.0046 6.02614Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.3995 9.1791C14.6959 9.20399 15.0612 9.41527 15.7918 9.83705L17.5101 10.829C18.091 11.1644 18.3817 11.3324 18.6134 11.5454C19.0415 11.9392 19.3392 12.4542 19.4661 13.0219C19.5348 13.3291 19.5347 13.6649 19.5347 14.3357V17.2622C19.5347 17.9327 19.5348 18.2681 19.4661 18.5752C19.3393 19.1427 19.0421 19.658 18.6141 20.0517C18.3825 20.2647 18.0922 20.4327 17.5116 20.7681L14.9754 22.2325C14.3945 22.5681 14.104 22.7362 13.8036 22.8305C13.2483 23.0046 12.6527 23.0046 12.0974 22.8305C11.7969 22.7363 11.5061 22.5682 10.9249 22.2325L9.20085 21.237C8.47009 20.8148 8.1046 20.6036 7.93491 20.3592C7.61775 19.9025 7.61799 19.2964 7.93562 18.84C8.10555 18.5958 8.47179 18.3851 9.203 17.9636L10.9306 16.9674C11.5123 16.6322 11.8033 16.4648 12.0353 16.2517C12.4639 15.858 12.7617 15.3425 12.8888 14.7745C12.9576 14.4671 12.958 14.1313 12.958 13.4601V11.4734C12.958 10.6298 12.9577 10.2075 13.0843 9.93838C13.3211 9.43551 13.8456 9.13264 14.3995 9.1791Z" fill="currentColor"/>
<path d="M10.1963 1.16953C10.7517 0.995411 11.3472 0.99541 11.9026 1.16953C12.203 1.26374 12.4934 1.43192 13.0743 1.76754L14.7984 2.76302C15.5291 3.18509 15.8946 3.3965 16.0644 3.64076C16.3816 4.09751 16.3813 4.7036 16.0637 5.16004C15.8937 5.40412 15.528 5.61504 14.797 6.03636L13.0686 7.03256C12.4873 7.36764 12.1966 7.53528 11.9647 7.74831C11.536 8.14207 11.2376 8.65743 11.1105 9.22548C11.0417 9.53287 11.042 9.8687 11.042 10.54V12.5266C11.042 13.3702 11.0423 13.7925 10.9157 14.0616C10.679 14.5645 10.1543 14.8673 9.60048 14.8209C9.30412 14.796 8.9387 14.5847 8.20822 14.163L6.48913 13.171C5.90836 12.8357 5.61828 12.6676 5.3866 12.4546C4.95845 12.0609 4.66076 11.5457 4.53384 10.9781C4.46517 10.6709 4.46533 10.3351 4.46533 9.66436V6.73784C4.46533 6.06733 4.46521 5.7319 4.53384 5.42479C4.6607 4.85729 4.95791 4.34203 5.38589 3.94833C5.61749 3.7353 5.9078 3.56727 6.48842 3.23187L9.02459 1.76754C9.60556 1.43193 9.89591 1.26375 10.1963 1.16953Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,6 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.28613 16.5527C6.39893 16.5584 6.49204 16.5605 6.56543 16.5605C7.65036 16.5831 8.49273 16.62 9.0918 16.6709C9.69654 16.7161 10.1887 16.7783 10.5674 16.8574L11.0508 19.0449C11.1525 19.4857 11.1409 19.8673 11.0166 20.1895C10.8923 20.5115 10.6887 20.7572 10.4062 20.9268C10.1237 21.102 9.80117 21.1895 9.43945 21.1895C8.91966 21.1894 8.51042 21.0566 8.21094 20.791C7.91139 20.5254 7.64795 20.1554 7.42188 19.6807L5.93848 16.5439C6.05715 16.5439 6.17311 16.5471 6.28613 16.5527Z" fill="currentColor"/>
<path d="M19.8252 2.81055C20.1811 2.81059 20.4944 2.88968 20.7656 3.04785C21.0424 3.20038 21.2576 3.41466 21.4102 3.69141C21.5684 3.96834 21.6475 4.28554 21.6475 4.6416V17.7646C21.6475 18.1151 21.5684 18.4291 21.4102 18.7061C21.2576 18.9829 21.0425 19.198 20.7656 19.3506C20.4945 19.503 20.181 19.5791 19.8252 19.5791C19.4691 19.5791 19.1519 19.5032 18.875 19.3506C18.6039 19.198 18.3897 18.9828 18.2314 18.7061C18.0788 18.4291 18.002 18.1151 18.002 17.7646V4.6416C18.002 4.28554 18.0788 3.96834 18.2314 3.69141C18.3896 3.41485 18.6041 3.20035 18.875 3.04785C19.1519 2.8896 19.4691 2.81055 19.8252 2.81055Z" fill="currentColor"/>
<path d="M16.7988 5.05664V17.417C16.8045 17.4679 16.8066 17.5163 16.8066 17.5615C15.9985 17.1659 15.1394 16.8212 14.2295 16.5273C13.3252 16.2278 12.4177 15.9822 11.5078 15.79C10.598 15.5923 9.73877 15.4478 8.93066 15.3574V7.1084C9.6767 7.02927 10.5082 6.89333 11.4238 6.70117C12.3392 6.50339 13.263 6.25455 14.1953 5.95508C15.1334 5.64991 16.0041 5.29988 16.8066 4.9043C16.801 4.95507 16.7988 5.00587 16.7988 5.05664Z" fill="currentColor"/>
<path d="M7.70996 15.3486H5.98926C4.80817 15.3486 3.90684 15.0492 3.28516 14.4502C2.66346 13.8455 2.35254 12.9749 2.35254 11.8389V10.627C2.35254 9.49094 2.66346 8.62037 3.28516 8.01562C3.90685 7.41093 4.80809 7.1084 5.98926 7.1084H7.70996V15.3486Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.1143 1.80515C17.3978 1.7458 17.6454 1.80166 17.8564 1.97312C18.0674 2.14456 18.1728 2.3786 18.1729 2.67527V6.38523C18.1728 6.57628 18.1168 6.74098 18.0049 6.87937C17.8928 7.01126 17.741 7.09761 17.5498 7.13718L12.999 8.13621C12.8014 8.18234 12.6695 8.24467 12.6035 8.32371C12.5442 8.40284 12.5147 8.55143 12.5146 8.76902V17.8305C12.5146 18.5362 12.3956 19.1462 12.1582 19.6606C11.9274 20.175 11.624 20.5943 11.248 20.9175C10.8722 21.2405 10.4632 21.4809 10.0215 21.6391C9.58623 21.7974 9.16376 21.8764 8.75488 21.8764C7.89773 21.8764 7.19573 21.6256 6.64844 21.1245C6.10114 20.6233 5.82722 19.9741 5.82715 19.1762C5.82715 18.398 6.07472 17.7512 6.56934 17.2368C7.06396 16.7225 7.82268 16.3567 8.84473 16.1391L10.4268 15.813C10.829 15.7272 11.0112 15.4596 10.9717 15.0112L10.9512 4.06003C10.9513 3.45352 11.3075 3.07403 12.0195 2.92234L17.1143 1.80515Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 968 B

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.8428 3.02246C15.2607 3.02246 16.5498 3.16895 17.71 3.46191C18.8701 3.74902 19.8662 4.16211 20.6982 4.70117C21.5361 5.24023 22.1807 5.88477 22.6318 6.63477C23.083 7.38477 23.3086 8.21973 23.3086 9.13965C23.3086 9.76074 23.1855 10.2939 22.9395 10.7393C22.6992 11.1787 22.3857 11.5566 21.999 11.873C21.6123 12.1836 21.2021 12.4531 20.7686 12.6816C20.3408 12.9043 19.9336 13.1094 19.5469 13.2969C19.166 13.4785 18.8525 13.666 18.6064 13.8594C18.3662 14.0469 18.2461 14.2578 18.2461 14.4922C18.2461 14.75 18.3193 14.9814 18.4658 15.1865C18.6123 15.3857 18.7793 15.5938 18.9668 15.8105C19.1602 16.0273 19.3301 16.2822 19.4766 16.5752C19.623 16.8682 19.6963 17.2402 19.6963 17.6914C19.6963 18.377 19.4238 18.9775 18.8789 19.4932C18.334 20.0146 17.5752 20.4189 16.6025 20.7061C15.6357 20.9932 14.502 21.1367 13.2012 21.1367C11.4668 21.1367 9.8877 20.9229 8.46387 20.4951C7.04004 20.0732 5.8125 19.4668 4.78125 18.6758C3.75586 17.8906 2.96484 16.9531 2.4082 15.8633C1.85156 14.7676 1.57324 13.5547 1.57324 12.2246C1.57324 10.8594 1.86621 9.61426 2.45215 8.48926C3.03809 7.3584 3.87305 6.38867 4.95703 5.58008C6.04102 4.76562 7.33594 4.13574 8.8418 3.69043C10.3477 3.24512 12.0146 3.02246 13.8428 3.02246ZM13.0781 12.7344C12.627 12.7344 12.2139 12.8486 11.8389 13.0771C11.4639 13.2998 11.1621 13.5986 10.9336 13.9736C10.7109 14.3486 10.5996 14.7646 10.5996 15.2217C10.5996 15.6787 10.7109 16.0947 10.9336 16.4697C11.1621 16.8447 11.4639 17.1465 11.8389 17.375C12.2139 17.5977 12.627 17.709 13.0781 17.709C13.5527 17.709 13.9775 17.5977 14.3525 17.375C14.7275 17.1523 15.0234 16.8535 15.2402 16.4785C15.4629 16.1035 15.5742 15.6846 15.5742 15.2217C15.5742 14.7646 15.46 14.3486 15.2314 13.9736C15.0088 13.5986 14.71 13.2998 14.335 13.0771C13.96 12.8486 13.541 12.7344 13.0781 12.7344ZM5.80957 10.3789C5.41113 10.3789 5.0625 10.5254 4.76367 10.8184C4.4707 11.1113 4.32422 11.457 4.32422 11.8555C4.32422 12.2656 4.4707 12.6201 4.76367 12.9189C5.05664 13.2119 5.40527 13.3584 5.80957 13.3584C6.21973 13.3584 6.56836 13.2119 6.85547 12.9189C7.14844 12.6201 7.29492 12.2656 7.29492 11.8555C7.29492 11.4453 7.14844 11.0967 6.85547 10.8096C6.56836 10.5225 6.21973 10.3789 5.80957 10.3789ZM9.29883 6.47656C8.7832 6.47656 8.34082 6.65527 7.97168 7.0127C7.6084 7.37012 7.42676 7.80371 7.42676 8.31348C7.42676 8.83496 7.6084 9.27441 7.97168 9.63184C8.34082 9.98926 8.7832 10.168 9.29883 10.168C9.80859 10.168 10.2451 9.98926 10.6084 9.63184C10.9717 9.27441 11.1533 8.83496 11.1533 8.31348C11.1533 7.80371 10.9717 7.37012 10.6084 7.0127C10.2451 6.65527 9.80859 6.47656 9.29883 6.47656ZM18.4043 7.04785C18.0645 7.04785 17.7744 7.16797 17.5342 7.4082C17.2939 7.64258 17.1738 7.92969 17.1738 8.26953C17.1738 8.60938 17.2939 8.89648 17.5342 9.13086C17.7744 9.36523 18.0645 9.48242 18.4043 9.48242C18.7383 9.48242 19.0225 9.3623 19.2568 9.12207C19.4912 8.88184 19.6084 8.59766 19.6084 8.26953C19.6084 7.94141 19.4912 7.65723 19.2568 7.41699C19.0225 7.1709 18.7383 7.04785 18.4043 7.04785ZM14.1152 5.73828C13.7051 5.73828 13.3506 5.88477 13.0518 6.17773C12.7529 6.46484 12.6035 6.81641 12.6035 7.23242C12.6035 7.64844 12.7529 8.00293 13.0518 8.2959C13.3506 8.58301 13.7051 8.72656 14.1152 8.72656C14.5312 8.72656 14.8828 8.58301 15.1699 8.2959C15.4629 8.00293 15.6094 7.64844 15.6094 7.23242C15.6094 6.81641 15.4629 6.46484 15.1699 6.17773C14.8828 5.88477 14.5312 5.73828 14.1152 5.73828Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9902 13.4937C13.276 13.4937 14.4333 13.6845 15.4619 14.0669C16.4907 14.4494 17.3683 14.9477 18.0938 15.561C18.8258 16.1744 19.3863 16.8307 19.7754 17.5298C20.1644 18.2287 20.3593 18.898 20.3594 19.5376C20.3594 19.9729 20.2003 20.3163 19.8838 20.5669C19.5673 20.8173 19.1319 20.9429 18.5781 20.9429H5.42188C4.8679 20.9429 4.43179 20.8175 4.11523 20.5669C3.79878 20.3163 3.64062 19.9728 3.64062 19.5376C3.64067 18.898 3.83238 18.2288 4.21484 17.5298C4.60395 16.8307 5.16127 16.1744 5.88672 15.561C6.61868 14.9478 7.4987 14.4494 8.52734 14.0669C9.55616 13.6844 10.7108 13.4937 11.9902 13.4937Z" fill="currentColor"/>
<path d="M12 3.05713C12.7188 3.05713 13.3816 3.24477 13.9883 3.62061C14.595 3.99652 15.0797 4.51165 15.4424 5.16455C15.8116 5.81076 15.9961 6.5359 15.9961 7.34033C15.9961 8.16459 15.8116 8.90966 15.4424 9.57568C15.0797 10.2352 14.595 10.7598 13.9883 11.1489C13.3816 11.538 12.7188 11.7329 12 11.7329C11.2812 11.7329 10.6184 11.538 10.0117 11.1489C9.41158 10.7598 8.92694 10.2352 8.55762 9.57568C8.19499 8.91625 8.01367 8.17753 8.01367 7.35986C8.01373 6.55545 8.19497 5.83028 8.55762 5.18408C8.92694 4.53118 9.41475 4.01288 10.0215 3.63037C10.6282 3.24794 11.2878 3.05713 12 3.05713Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.8135 3.79102C20.8064 3.79105 21.5559 4.04418 22.0615 4.5498C22.5669 5.04934 22.8193 5.78624 22.8193 6.76074V17.8916C22.8193 18.8661 22.567 19.603 22.0615 20.1025C21.5559 20.6021 20.8064 20.8525 19.8135 20.8525H4.16895C3.18201 20.8525 2.43534 20.6021 1.92969 20.1025C1.42414 19.603 1.17095 18.8662 1.1709 17.8916V6.76074C1.17092 5.78616 1.42417 5.04935 1.92969 4.5498C2.43534 4.04415 3.18201 3.79102 4.16895 3.79102H19.8135ZM15.6006 11.8965C15.0645 11.8965 14.58 12.0888 14.1475 12.4727L9.8252 16.3193L8.08887 14.7568C7.68683 14.3913 7.25126 14.209 6.78223 14.209C6.57522 14.209 6.36814 14.2544 6.16113 14.3457C5.96016 14.431 5.75859 14.5617 5.55762 14.7383L2.97168 16.9961V17.7178C2.9717 18.1381 3.09055 18.4675 3.32812 18.7051C3.56569 18.9424 3.89163 19.0605 4.30566 19.0605H19.6943C20.0964 19.0605 20.4167 18.9454 20.6543 18.7139C20.8979 18.4763 21.0195 18.1504 21.0195 17.7363V16.1729L17.0264 12.4814C16.8193 12.2865 16.5906 12.1404 16.3408 12.043C16.0911 11.9455 15.8443 11.8965 15.6006 11.8965ZM8.29883 7.94922C7.87854 7.94925 7.49466 8.05266 7.14746 8.25977C6.80647 8.46684 6.53227 8.74374 6.3252 9.09082C6.11806 9.43198 6.01465 9.81034 6.01465 10.2246C6.01471 10.6448 6.11812 11.0288 6.3252 11.376C6.5323 11.717 6.8064 11.9912 7.14746 12.1982C7.49466 12.4053 7.87854 12.5088 8.29883 12.5088C8.70693 12.5088 9.08174 12.4053 9.42285 12.1982C9.77008 11.9911 10.0478 11.7171 10.2549 11.376C10.462 11.0288 10.5654 10.6448 10.5654 10.2246C10.5654 9.81034 10.462 9.43198 10.2549 9.09082C10.0478 8.74361 9.77007 8.46688 9.42285 8.25977C9.0817 8.05264 8.70699 7.94922 8.29883 7.94922Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.94798 3.30664C8.01975 3.30672 7.31901 3.54355 6.84599 4.01658C6.36709 4.48377 6.12781 5.17293 6.12781 6.08354V9.69403C6.12772 9.78818 6.09795 9.84989 6.03919 9.8795C5.9742 9.90313 5.89698 9.88595 5.80838 9.82695C5.65462 9.71459 5.49148 9.61375 5.31998 9.52505C5.14264 9.4364 4.9532 9.36495 4.75223 9.31176C4.55132 9.25862 4.33558 9.23243 4.10515 9.23242C3.5729 9.23242 3.09924 9.35368 2.68527 9.59614C2.26556 9.83859 1.93437 10.173 1.69197 10.5987C1.44962 11.0185 1.32825 11.4947 1.32825 12.0268C1.32828 12.5531 1.44954 13.0292 1.69197 13.455C1.93441 13.8807 2.26548 14.2151 2.68527 14.4575C3.09924 14.7 3.5729 14.8213 4.10515 14.8213C4.33563 14.8212 4.55128 14.794 4.75223 14.7409C4.95314 14.6877 5.14269 14.6172 5.31998 14.5286C5.49148 14.4399 5.65462 14.3414 5.80838 14.235C5.89695 14.1701 5.97422 14.1529 6.03919 14.1824C6.09809 14.2062 6.12781 14.2652 6.12781 14.3596V17.9083C6.12787 18.8247 6.36713 19.5164 6.84599 19.9835C7.31901 20.4565 8.01975 20.6934 8.94798 20.6934H19.8773C20.7938 20.6934 21.4887 20.4541 21.9618 19.9753C22.4349 19.5022 22.6717 18.804 22.6718 17.8815V14.1206C22.6718 13.8072 22.5676 13.5579 22.3606 13.3746C22.1538 13.1916 21.9117 13.0996 21.6341 13.0995C21.4746 13.0995 21.3146 13.1357 21.155 13.2066C20.9894 13.2776 20.8353 13.3841 20.6934 13.5261C20.5634 13.6561 20.4124 13.7946 20.2411 13.9423C20.0696 14.0842 19.8598 14.2089 19.6115 14.3153C19.3631 14.4159 19.064 14.4658 18.7151 14.4658C18.2717 14.4657 17.8668 14.3568 17.5002 14.1381C17.1336 13.9193 16.8435 13.6258 16.6306 13.2592C16.4119 12.8926 16.3019 12.4844 16.3019 12.0351C16.3079 11.5799 16.4211 11.1693 16.6398 10.8027C16.8527 10.4302 17.1418 10.1367 17.5085 9.92381C17.8692 9.71093 18.2716 9.60441 18.7151 9.60439C19.064 9.60439 19.3631 9.65532 19.6115 9.75585C19.8596 9.85633 20.0697 9.98011 20.2411 10.1278C20.4125 10.2697 20.5633 10.4058 20.6934 10.5359C20.8353 10.6837 20.9894 10.7936 21.155 10.8646C21.3146 10.9295 21.4746 10.9624 21.6341 10.9624C21.9119 10.9623 22.1537 10.8728 22.3606 10.6956C22.5675 10.5123 22.6717 10.2615 22.6718 9.94235V6.11858C22.6717 5.19021 22.4348 4.48967 21.9618 4.01658C21.4887 3.54348 20.7939 3.30665 19.8773 3.30664H8.94798Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.39681 21.5464C5.17464 21.3813 5.03817 21.1592 4.98738 20.8799C4.94295 20.6069 4.98421 20.2832 5.11116 19.9087L7.07259 14.0911L2.07381 10.5015C1.75008 10.2729 1.52474 10.0349 1.39778 9.78735C1.27083 9.53345 1.25179 9.27319 1.34066 9.00659C1.42952 8.74634 1.59774 8.55273 1.84529 8.42578C2.09285 8.29883 2.41658 8.23535 2.81649 8.23535H8.9388L10.805 2.42725C10.9256 2.04639 11.0843 1.75757 11.2811 1.56079C11.4842 1.35767 11.7222 1.2561 11.9952 1.2561C12.2745 1.2561 12.5125 1.35767 12.7093 1.56079C12.9124 1.75757 13.0743 2.04639 13.1949 2.42725L15.0611 8.23535H21.1834C21.5833 8.23535 21.9071 8.29883 22.1546 8.42578C22.4022 8.55273 22.5704 8.74634 22.6593 9.00659C22.7481 9.27319 22.7291 9.53345 22.6021 9.78735C22.4752 10.0349 22.2498 10.2729 21.9261 10.5015L16.9273 14.0911L18.8888 19.9087C19.0157 20.2832 19.0538 20.6069 19.003 20.8799C18.9586 21.1592 18.8253 21.3813 18.6031 21.5464C18.3809 21.7241 18.1302 21.7876 17.8509 21.7368C17.5716 21.686 17.2733 21.5464 16.9559 21.3179L11.9952 17.6711L7.04402 21.3179C6.72664 21.5464 6.4283 21.686 6.149 21.7368C5.86971 21.7876 5.61898 21.7241 5.39681 21.5464Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6562 3.50635C20.6529 3.50638 21.4094 3.76446 21.9258 4.28076C22.4422 4.79717 22.7002 5.54793 22.7002 6.53271V17.4761C22.7002 18.4549 22.4422 19.2028 21.9258 19.7192C21.4094 20.2356 20.6529 20.4936 19.6562 20.4937H4.35254C3.35596 20.4936 2.59657 20.2355 2.07422 19.7192C1.55781 19.2028 1.2998 18.4549 1.2998 17.4761V6.53271C1.2998 5.54192 1.55781 4.79117 2.07422 4.28076C2.59657 3.76451 3.35601 3.50641 4.35254 3.50635H19.6562ZM10.0186 11.1987C9.8204 11.1987 9.649 11.2704 9.50488 11.4146C9.36679 11.5527 9.29785 11.7241 9.29785 11.9282C9.2979 12.1323 9.36682 12.3066 9.50488 12.4507C9.64298 12.5947 9.81443 12.6665 10.0186 12.6665H13.5137C13.7175 12.6664 13.8884 12.5945 14.0264 12.4507C14.1704 12.3066 14.2431 12.1323 14.2432 11.9282C14.2432 11.7241 14.1705 11.5527 14.0264 11.4146C13.8884 11.2706 13.7176 11.1988 13.5137 11.1987H10.0186ZM6.17188 7.07275C5.93188 6.92288 5.71546 6.87798 5.52344 6.93799C5.33144 6.99806 5.18127 7.11272 5.07324 7.28076C4.97141 7.4487 4.9359 7.6345 4.96582 7.83838C5.00185 8.04254 5.12758 8.20837 5.34375 8.33447L7.35254 9.5415L5.35254 10.7661C5.13055 10.8981 5.00192 11.0632 4.96582 11.2612C4.93581 11.4593 4.97123 11.6457 5.07324 11.8198C5.17531 11.9939 5.32254 12.1113 5.51465 12.1714C5.71277 12.2314 5.93173 12.1857 6.17188 12.0356L8.79297 10.3882C9.00307 10.2561 9.13914 10.0818 9.19922 9.86572C9.26526 9.64959 9.26147 9.43621 9.18945 9.22607C9.1234 9.0099 8.99113 8.8385 8.79297 8.7124L6.17188 7.07275Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
@@ -1,4 +1,4 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { ComponentPropsWithRef, PropsWithChildren } from 'react';
import { createGlobalStyle } from 'styled-components';
@@ -1,4 +1,4 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import { Alert, VariantType } from '@gouvfr-lasuite/cunningham-react';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -1,17 +1,17 @@
import { memo, useMemo } from 'react';
import { ComponentType, SVGProps, memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { ChatConversation } from '@/features/chat/types';
import { PROJECT_ICONS } from '@/features/left-panel/components/projects/project-constants';
import { getRelativeTime } from '@/utils';
import ArrowForwardIcon from './assets/arrow-forward.svg';
import BubbleIcon from './assets/bubble.svg';
const descriptionCss = css`
color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 400;
`;
@@ -23,14 +23,19 @@ export const QuickSearchResultItem = memo(function QuickSearchResultItem({
conversation,
}: QuickSearchResultItemProps) {
const { t, i18n } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const title = conversation.title || t('Untitled conversation');
const project = conversation.project;
const projectTitle = project?.title?.trim();
const updatedAtLabel = useMemo(
() => getRelativeTime(conversation.updated_at, i18n.language),
[conversation.updated_at, i18n.language],
);
const ProjectIcon: ComponentType<SVGProps<SVGSVGElement>> | undefined =
project?.icon ? (PROJECT_ICONS[project.icon] ?? undefined) : undefined;
return (
<Box
$direction="row"
@@ -44,11 +49,27 @@ export const QuickSearchResultItem = memo(function QuickSearchResultItem({
<Text aria-label={title} $size="sm" $shrink={1}>
{title}
</Text>
{updatedAtLabel && (
<Text $css={descriptionCss} $size="xs" $theme="greyscale" $shrink={0}>
{updatedAtLabel}
</Text>
)}
<Text $css={descriptionCss} $size="xs" $shrink={0}>
<Box
$direction="row"
$align="center"
$gap="4px"
as="span"
$display="inline-flex"
style={{ color: colorsTokens['gray-500'] }}
>
{projectTitle && (
<>
{ProjectIcon && (
<ProjectIcon width={12} height={12} aria-hidden="true" />
)}
<span>{projectTitle}</span>
{updatedAtLabel && <span>&bull;</span>}
</>
)}
{updatedAtLabel && <span>{updatedAtLabel}</span>}
</Box>
</Text>
</Box>
<Box
className="show-right-on-focus"
@@ -0,0 +1,62 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { render, screen } from '@testing-library/react';
import { ChatConversation } from '@/features/chat/types';
import { QuickSearchResultItem } from '../QuickSearchResultItem';
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: 'en' },
}),
}));
jest.mock('i18next', () => ({
t: (key: string) => key,
}));
const renderWithProviders = (component: React.ReactNode) =>
render(<CunninghamProvider>{component}</CunninghamProvider>);
const makeConversation = (
overrides?: Partial<ChatConversation>,
): ChatConversation => ({
id: 'conv-1',
title: 'Test conversation',
messages: [],
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-05T00:00:00Z',
...overrides,
});
describe('QuickSearchResultItem', () => {
it('renders project title, bullet separator, and relative date when conversation has a project', () => {
const conversation = makeConversation({
project: { id: 'proj-1', title: 'My Project', icon: 'folder' },
});
renderWithProviders(<QuickSearchResultItem conversation={conversation} />);
expect(screen.getByText('Test conversation')).toBeInTheDocument();
expect(screen.getByText('My Project')).toBeInTheDocument();
// Bullet separator between project name and date
expect(screen.getByText('\u2022')).toBeInTheDocument();
});
it('renders only relative date when conversation has no project', () => {
const conversation = makeConversation({ project: null });
renderWithProviders(<QuickSearchResultItem conversation={conversation} />);
expect(screen.getByText('Test conversation')).toBeInTheDocument();
expect(screen.queryByText('\u2022')).not.toBeInTheDocument();
});
it('shows "Untitled conversation" when title is empty', () => {
const conversation = makeConversation({ title: '' });
renderWithProviders(<QuickSearchResultItem conversation={conversation} />);
expect(screen.getByText('Untitled conversation')).toBeInTheDocument();
});
});
@@ -16,7 +16,7 @@ const ToastProviderNoSSR = dynamic(
const CunninghamProviderNoSSR = dynamic(
() =>
import('@openfun/cunningham-react').then((mod) => ({
import('@gouvfr-lasuite/cunningham-react').then((mod) => ({
default: mod.CunninghamProvider,
})),
{ ssr: false },
@@ -1,4 +1,4 @@
import { Loader } from '@openfun/cunningham-react';
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import Head from 'next/head';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { Resource } from 'i18next';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
import { BaseTheme } from '@/cunningham/';
import { FooterType } from '@/features/footer';
import { PostHogConf } from '@/services';
@@ -28,7 +28,7 @@ export interface ConfigResponse {
FEATURE_FLAGS: FeatureFlags;
FRONTEND_CSS_URL?: string;
FRONTEND_HOMEPAGE_FEATURE_ENABLED?: boolean;
FRONTEND_THEME?: Theme;
FRONTEND_THEME?: BaseTheme;
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
MEDIA_BASE_URL?: string;
@@ -1,9 +1,7 @@
import merge from 'lodash/merge';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
import { safeLocalStorage } from '@/utils/storages';
import { tokens } from './cunningham-tokens';
@@ -19,6 +17,7 @@ type ComponentTokens = Partial<
Record<string, unknown>;
type ContextualTokens = Tokens['contextuals'];
export type Theme = keyof typeof tokens.themes;
export type BaseTheme = Exclude<Theme, 'dark' | 'dsfr-dark'>;
interface ThemeStore {
colorsTokens: Partial<ColorsTokens>;
@@ -26,12 +25,11 @@ interface ThemeStore {
contextualTokens: ContextualTokens;
currentTokens: Partial<Tokens>;
fontSizesTokens: Partial<FontSizesTokens>;
setTheme: (theme: Theme) => void;
setTheme: (theme: BaseTheme) => void;
spacingsTokens: Partial<SpacingsTokens>;
theme: Theme;
baseTheme: Theme; // 'default' or 'dsfr' (not persisted)
baseTheme: BaseTheme;
themeTokens: Partial<Tokens['globals']>;
isDarkMode: boolean;
toggleDarkMode: () => void;
}
@@ -50,18 +48,9 @@ const getComponentTokens = (
);
};
const DEFAULT_THEME: Theme = 'default';
const DEFAULT_THEME: BaseTheme = 'default';
const defaultTokens = getMergedTokens(DEFAULT_THEME);
// Helper to get isDarkMode from useChatPreferencesStore
const getIsDarkModeFromPreferences = (): boolean => {
try {
return useChatPreferencesStore.getState().isDarkModePreference ?? false;
} catch {
return false;
}
};
const initialState: ThemeStore = {
colorsTokens: defaultTokens.globals.colors,
componentTokens: getComponentTokens(defaultTokens),
@@ -73,91 +62,52 @@ const initialState: ThemeStore = {
theme: DEFAULT_THEME,
baseTheme: DEFAULT_THEME,
themeTokens: defaultTokens.globals,
isDarkMode: getIsDarkModeFromPreferences(),
toggleDarkMode: () => {},
};
export const useCunninghamTheme = create<ThemeStore>()(
persist(
(set) => ({
...initialState,
setTheme: (theme: Theme) => {
// Extract base theme (default or dsfr)
const baseTheme: Theme =
theme === 'dark' || theme === 'dsfr-dark'
? theme === 'dark'
? 'default'
: 'dsfr'
: theme;
const getIsDarkMode = () =>
useChatPreferencesStore.getState?.()?.isDarkModePreference ?? false;
const isDarkMode =
getIsDarkModeFromPreferences() ??
(theme === 'dark' || theme === 'dsfr-dark');
const resolveTheme = (baseTheme: BaseTheme, isDarkMode: boolean): Theme => {
if (!isDarkMode) {
return baseTheme;
}
return baseTheme === 'dsfr' ? 'dsfr-dark' : 'dark';
};
// Apply dark mode based on stored preference or theme
const finalTheme: Theme = isDarkMode
? baseTheme === 'dsfr'
? 'dsfr-dark'
: 'dark'
: baseTheme;
const computeThemeState = (baseTheme: BaseTheme, isDarkMode: boolean) => {
const theme = resolveTheme(baseTheme, isDarkMode);
const mergedTokens = getMergedTokens(theme);
return {
colorsTokens: mergedTokens.globals.colors,
componentTokens: getComponentTokens(mergedTokens),
contextualTokens: mergedTokens.contextuals,
currentTokens: tokens.themes[theme] as Partial<Tokens>,
fontSizesTokens: mergedTokens.globals.font.sizes,
spacingsTokens: mergedTokens.globals.spacings,
theme,
baseTheme,
themeTokens: mergedTokens.globals,
};
};
const newTokens = getMergedTokens(finalTheme);
export const useCunninghamTheme = create<ThemeStore>()((set) => ({
...initialState,
...computeThemeState(DEFAULT_THEME, getIsDarkMode()),
setTheme: (baseTheme: BaseTheme) => {
set(computeThemeState(baseTheme, getIsDarkMode()));
},
toggleDarkMode: () => {
useChatPreferencesStore.getState().toggleDarkModePreferences();
},
}));
set({
colorsTokens: newTokens.globals.colors,
componentTokens: getComponentTokens(newTokens),
contextualTokens: newTokens.contextuals,
currentTokens: tokens.themes[finalTheme] as Partial<Tokens>,
fontSizesTokens: newTokens.globals.font.sizes,
spacingsTokens: newTokens.globals.spacings,
theme: finalTheme,
baseTheme,
themeTokens: newTokens.globals,
isDarkMode,
});
},
toggleDarkMode: () => {
useChatPreferencesStore.getState().toggleDarkModePreferences();
set((state) => {
const newIsDarkMode = getIsDarkModeFromPreferences();
const newTheme: Theme = newIsDarkMode
? state.baseTheme === 'dsfr'
? 'dsfr-dark'
: 'dark'
: state.baseTheme;
const newTokens = getMergedTokens(newTheme);
return {
colorsTokens: newTokens.globals.colors,
componentTokens: getComponentTokens(newTokens),
contextualTokens: newTokens.contextuals,
currentTokens: tokens.themes[newTheme] as Partial<Tokens>,
fontSizesTokens: newTokens.globals.font.sizes,
spacingsTokens: newTokens.globals.spacings,
theme: newTheme,
baseTheme: state.baseTheme,
themeTokens: newTokens.globals,
isDarkMode: newIsDarkMode,
};
});
},
}),
{
name: 'cunningham-theme',
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
storage: safeLocalStorage as any,
partialize: (state) => ({ isDarkMode: state.isDarkMode }),
onRehydrateStorage: () => (state, error) => {
if (error) {
console.error('[useCunninghamTheme] Rehydration error:', error);
return;
}
if (state) {
state.isDarkMode = getIsDarkModeFromPreferences();
}
},
},
),
);
// Sync theme when isDarkModePreference changes (e.g. persist rehydration)
useChatPreferencesStore.subscribe?.((state, prev) => {
if (state.isDarkModePreference !== prev.isDarkModePreference) {
const { baseTheme } = useCunninghamTheme.getState();
useCunninghamTheme.setState(
computeThemeState(baseTheme, state.isDarkModePreference),
);
}
});
@@ -1,4 +1,4 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -0,0 +1,37 @@
import fetchMock from 'fetch-mock';
import { getConversations } from '../useConversations';
const API_BASE = 'http://test.jest/api/v1.0/';
describe('getConversations', () => {
beforeEach(() => {
fetchMock.restore();
});
it('sends project=none when title is not provided', async () => {
fetchMock.get(`begin:${API_BASE}chats/`, {
status: 200,
body: { count: 0, results: [], next: null, previous: null },
});
await getConversations({ page: 1 });
const url = fetchMock.lastUrl()!;
expect(url).toContain('project=none');
expect(url).not.toContain('title=');
});
it('omits project param when title is provided', async () => {
fetchMock.get(`begin:${API_BASE}chats/`, {
status: 200,
body: { count: 0, results: [], next: null, previous: null },
});
await getConversations({ page: 1, title: 'search term' });
const url = fetchMock.lastUrl()!;
expect(url).not.toContain('project=');
expect(url).toContain('title=search+term');
});
});
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { fetchAPI } from '@/api';
import { KEY_LIST_CONVERSATION } from '@/features/chat/api/useConversations';
import { KEY_LIST_PROJECT } from '@/features/chat/api/useProjects';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
const fetchAPIAdapter = (input: RequestInfo | URL, init?: RequestInit) => {
@@ -76,6 +77,9 @@ export function useChat(options: Omit<UseChatOptions, 'fetch'>) {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_CONVERSATION],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PROJECT],
});
}
}
}
@@ -1,5 +1,3 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { ChatConversation } from '@/features/chat/types';
@@ -25,18 +23,3 @@ export const getConversation = async ({
};
export const KEY_CONVERSATION = 'conversation';
export function useConversations(
param: ConversationsParams,
queryConfig?: UseQueryOptions<
ConversationResponse,
APIError,
ConversationResponse
>,
) {
return useQuery<ConversationResponse, APIError, ConversationResponse>({
queryKey: [KEY_CONVERSATION, param],
queryFn: () => getConversation(param),
...queryConfig,
});
}
@@ -1,5 +1,3 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
@@ -43,6 +41,10 @@ export const getConversations = async (
searchParams.set('title', params.title);
}
if (!params.title) {
searchParams.set('project', 'none');
}
const response = await fetchAPI(`chats/?${searchParams.toString()}`);
if (!response.ok) {
@@ -57,21 +59,6 @@ export const getConversations = async (
export const KEY_LIST_CONVERSATION = 'conversations';
export function useConversations(
param: ConversationsParams,
queryConfig?: UseQueryOptions<
ConversationsResponse,
APIError,
ConversationsResponse
>,
) {
return useQuery<ConversationsResponse, APIError, ConversationsResponse>({
queryKey: [KEY_LIST_CONVERSATION, param],
queryFn: () => getConversations(param),
...queryConfig,
});
}
export const useInfiniteConversations = (
params: ConversationsParams,
queryConfig?: Partial<
@@ -5,19 +5,25 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { ChatConversation } from '../types';
import { KEY_LIST_CONVERSATION } from './useConversations';
import { KEY_LIST_PROJECT } from './useProjects';
interface ChatConversationParams {
title: string;
project?: string;
}
export const createChatConversation = async ({
title,
project,
}: ChatConversationParams): Promise<ChatConversation> => {
const body: Record<string, string> = { title };
if (project) {
body.project = project;
}
const response = await fetchAPI(`chats/`, {
method: 'POST',
body: JSON.stringify({
title,
}),
body: JSON.stringify(body),
});
if (!response.ok) {
@@ -34,10 +40,15 @@ export function useCreateChatConversation() {
const queryClient = useQueryClient();
return useMutation<ChatConversation, APIError, ChatConversationParams>({
mutationFn: createChatConversation,
onSuccess: () => {
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_CONVERSATION],
});
if (variables.project) {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PROJECT],
});
}
},
});
}
@@ -0,0 +1,62 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { ChatProject } from '@/features/chat/types';
import { KEY_LIST_PROJECT } from './useProjects';
interface CreateProjectProps {
title: string;
icon: string;
color: string;
llm_instructions?: string;
}
export const createProject = async (
props: CreateProjectProps,
): Promise<ChatProject> => {
const response = await fetchAPI('projects/', {
method: 'POST',
body: JSON.stringify(props),
});
if (!response.ok) {
throw new APIError(
'Failed to create the project',
await errorCauses(response),
);
}
return response.json() as Promise<ChatProject>;
};
type UseCreateProjectOptions = UseMutationOptions<
ChatProject,
APIError,
CreateProjectProps
>;
export const useCreateProject = (options?: UseCreateProjectOptions) => {
const queryClient = useQueryClient();
return useMutation<ChatProject, APIError, CreateProjectProps>({
mutationFn: createProject,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PROJECT],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
});
};
@@ -0,0 +1,79 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { ChatProject } from '@/features/chat/types';
const _projectsOrdering = [
'title',
'-title',
'created_at',
'-created_at',
'updated_at',
'-updated_at',
] as const;
export type ProjectsOrdering = (typeof _projectsOrdering)[number];
export type ProjectsParams = {
page: number;
ordering?: ProjectsOrdering;
title?: string;
page_size?: number;
};
export type ProjectsResponse = APIList<ChatProject>;
export const getProjects = async (
params: ProjectsParams,
): Promise<ProjectsResponse> => {
const searchParams = new URLSearchParams();
if (params.page) {
searchParams.set('page', params.page.toString());
}
if (params.ordering) {
searchParams.set('ordering', params.ordering);
}
if (params.title) {
searchParams.set('title', params.title);
}
if (params.page_size) {
searchParams.set('page_size', params.page_size.toString());
}
const response = await fetchAPI(`projects/?${searchParams.toString()}`);
if (!response.ok) {
throw new APIError(
'Failed to get the projects',
await errorCauses(response),
);
}
return response.json() as Promise<ProjectsResponse>;
};
export const KEY_LIST_PROJECT = 'projects';
export function useProjects(
param: ProjectsParams,
queryConfig?: UseQueryOptions<ProjectsResponse, APIError, ProjectsResponse>,
) {
return useQuery<ProjectsResponse, APIError, ProjectsResponse>({
queryKey: [KEY_LIST_PROJECT, param],
queryFn: () => getProjects(param),
...queryConfig,
});
}
export const useInfiniteProjects = (params: ProjectsParams) => {
return useAPIInfiniteQuery(KEY_LIST_PROJECT, getProjects, params);
};
@@ -7,6 +7,7 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_CONVERSATION } from './useConversations';
import { KEY_LIST_PROJECT } from './useProjects';
interface RemoveConversationProps {
conversationId: string;
@@ -44,6 +45,9 @@ export const useRemoveConversation = (
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_CONVERSATION],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PROJECT],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
@@ -0,0 +1,55 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_PROJECT } from './useProjects';
interface RemoveProjectProps {
projectId: string;
}
export const removeProject = async ({
projectId,
}: RemoveProjectProps): Promise<void> => {
const response = await fetchAPI(`projects/${projectId}/`, {
method: 'DELETE',
});
if (!response.ok) {
throw new APIError(
'Failed to delete the project',
await errorCauses(response),
);
}
};
type UseRemoveProjectOptions = UseMutationOptions<
void,
APIError,
RemoveProjectProps
>;
export const useRemoveProject = (options?: UseRemoveProjectOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, RemoveProjectProps>({
mutationFn: removeProject,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PROJECT],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
});
};
@@ -7,6 +7,7 @@ import {
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_CONVERSATION } from './useConversations';
import { KEY_LIST_PROJECT } from './useProjects';
interface RenameConversationProps {
conversationId: string;
@@ -49,6 +50,9 @@ export const useRenameConversation = (
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_CONVERSATION],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PROJECT],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
@@ -0,0 +1,61 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_PROJECT } from './useProjects';
interface UpdateProjectProps {
projectId: string;
title?: string;
icon?: string;
color?: string;
llm_instructions?: string;
}
export const updateProject = async ({
projectId,
...fields
}: UpdateProjectProps): Promise<void> => {
const response = await fetchAPI(`projects/${projectId}/`, {
method: 'PATCH',
body: JSON.stringify(fields),
});
if (!response.ok) {
throw new APIError(
'Failed to update the project',
await errorCauses(response),
);
}
};
type UseUpdateProjectOptions = UseMutationOptions<
void,
APIError,
UpdateProjectProps
>;
export const useUpdateProject = (options?: UseUpdateProjectOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, UpdateProjectProps>({
mutationFn: updateProject,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_PROJECT],
});
if (options?.onSuccess) {
void options.onSuccess(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
void options.onError(error, variables, context);
}
},
});
};
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box, Icon, Text } from '@/components';
@@ -1,5 +1,6 @@
import { Message, SourceUIPart } from '@ai-sdk/ui-utils';
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { InfiniteData, useQueryClient } from '@tanstack/react-query';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import { useRouter } from 'next/router';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -16,6 +17,10 @@ import {
LLMModel,
useLLMConfiguration,
} from '@/features/chat/api/useLLMConfiguration';
import {
KEY_LIST_PROJECT,
ProjectsResponse,
} from '@/features/chat/api/useProjects';
import { ChatError } from '@/features/chat/components/ChatError';
import { InputChat } from '@/features/chat/components/InputChat';
import { MessageItem } from '@/features/chat/components/MessageItem';
@@ -159,13 +164,20 @@ export const Chat = ({
} | null>(null);
const { mutate: createChatConversation } = useCreateChatConversation();
const queryClient = useQueryClient();
const [isReadingInstructions, setIsReadingInstructions] = useState(false);
const readingInstructionsStartRef = useRef<number>(0);
// Zustand store for pending chat state
const {
input: pendingInput,
files: pendingFiles,
projectId: pendingProjectId,
hasProjectInstructions,
setPendingChat,
clearPendingChat,
setProjectId,
setHasProjectInstructions,
clearPendingInput,
} = usePendingChatStore();
const scrollToBottom = useCallback(() => {
@@ -332,6 +344,23 @@ export const Chat = ({
}
}, [messages]);
// Clear "reading instructions" once streaming begins or on error, with minimum display time
useEffect(() => {
if (isReadingInstructions) {
if (status === 'error') {
setIsReadingInstructions(false);
} else if (status === 'streaming') {
const elapsed = Date.now() - readingInstructionsStartRef.current;
const remaining = Math.max(0, 1500 - elapsed);
const timer = setTimeout(
() => setIsReadingInstructions(false),
remaining,
);
return () => clearTimeout(timer);
}
}
}, [status, isReadingInstructions]);
// Détecter l'arrivée d'un nouveau message user et retirer la hauteur de l'ancien
useEffect(() => {
if (status === 'streaming') {
@@ -399,6 +428,10 @@ export const Chat = ({
(pendingInput && pendingInput.trim()) ||
(pendingFiles && pendingFiles.length > 0)
) {
if (hasProjectInstructions) {
readingInstructionsStartRef.current = Date.now();
setIsReadingInstructions(true);
}
if (pendingInput) {
const syntheticEvent = {
target: { value: pendingInput },
@@ -409,10 +442,9 @@ export const Chat = ({
setFiles(pendingFiles);
}
setShouldAutoSubmit(true);
clearPendingChat();
} else {
clearPendingChat();
}
// Clear input/files but keep projectId alive until conversation is created
clearPendingInput();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -502,6 +534,18 @@ export const Chat = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasInitialized, messages.length]);
// Check if the current project has custom LLM instructions
const checkProjectHasInstructions = useCallback(() => {
if (!pendingProjectId) return false;
const projectsData = queryClient.getQueryData<
InfiniteData<ProjectsResponse>
>([KEY_LIST_PROJECT, { page: 1 }]);
const project = projectsData?.pages
.flatMap((page) => page.results)
.find((p) => p.id === pendingProjectId);
return !!project?.llm_instructions?.trim();
}, [pendingProjectId, queryClient]);
// Custom handleSubmit to include attachments and handle chat creation
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -538,10 +582,17 @@ export const Chat = ({
setPendingFirstMessage({ event, attachments, forceWebSearch });
// Save input and files to Zustand store before navigation
setPendingChat(input, files);
if (checkProjectHasInstructions()) {
setHasProjectInstructions(true);
}
void createChatConversation(
{ title: input.length > 100 ? `${input.slice(0, 97)}...` : input },
{
title: input.length > 100 ? `${input.slice(0, 97)}...` : input,
...(pendingProjectId && { project: pendingProjectId }),
},
{
onSuccess: (data) => {
setProjectId(null);
setConversationId(data.id);
// Update the URL to /chat/[id]/
void router.push(`/chat/${data.id}/`);
@@ -671,7 +722,12 @@ export const Chat = ({
>
<Loader />
<Text $variation="600" $size="md">
{isUploadingFiles ? t('Uploading files...') : t('Thinking...')}
{(() => {
if (isUploadingFiles) return t('Uploading files...');
if (isReadingInstructions)
return t('Reading project instructions...');
return t('Thinking...');
})()}
</Text>
</Box>
) : null}
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,6 +12,7 @@ import { useToast } from '@/components/ToastProvider';
import { FeatureFlagState, useConfig } from '@/core';
import { LLMModel } from '@/features/chat/api/useLLMConfiguration';
import { InputChatActions } from '@/features/chat/components/InputChatAction';
import { ProjectWelcomeMessage } from '@/features/chat/components/ProjectWelcomeMessage';
import { SuggestionCarousel } from '@/features/chat/components/SuggestionCarousel';
import { WelcomeMessage } from '@/features/chat/components/WelcomeMessage';
import { useFileDragDrop } from '@/features/chat/hooks/useFileDragDrop';
@@ -415,7 +416,9 @@ export const InputChat = ({
</Box>
)}
{/* Message de bienvenue */}
{messagesLength === 0 && <WelcomeMessage />}
{messagesLength === 0 && (
<ProjectWelcomeMessage fallback={<WelcomeMessage />} />
)}
<form onSubmit={handleSubmit} style={STYLES.form}>
<Box $padding={formPadding}>
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import Image from 'next/image';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -0,0 +1,62 @@
import { type ReactNode, memo, useMemo } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useInfiniteProjects } from '@/features/chat/api/useProjects';
import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore';
import {
PROJECT_COLORS,
PROJECT_ICONS,
} from '@/features/left-panel/components/projects/project-constants';
import { WelcomeLayout } from './WelcomeLayout';
interface ProjectWelcomeMessageProps {
fallback: ReactNode;
}
export const ProjectWelcomeMessage = memo(function ProjectWelcomeMessage({
fallback,
}: ProjectWelcomeMessageProps) {
const projectId = usePendingChatStore((s) => s.projectId);
const { colorsTokens } = useCunninghamTheme();
const projects = useInfiniteProjects({ page: 1 });
const project = useMemo(() => {
if (!projectId) return undefined;
return projects.data?.pages
.flatMap((page) => page.results)
.find((p) => p.id === projectId);
}, [projectId, projects.data]);
if (!project) {
return <>{fallback}</>;
}
const IconComp = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder;
const iconColor =
colorsTokens[PROJECT_COLORS[project.color] as keyof typeof colorsTokens] ??
undefined;
return (
<WelcomeLayout
title={project.title}
icon={
<Box
$display="flex"
$align="center"
$justify="center"
$width="32px"
$height="32px"
style={{ color: iconColor, flexShrink: 0 }}
>
<IconComp
width={32}
height={32}
style={{ fill: 'currentColor', display: 'block' }}
/>
</Box>
}
/>
);
});
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import React, { useEffect, useState } from 'react';
import { Box, Icon } from '@/components';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Icon, Text } from '@/components';
@@ -0,0 +1,32 @@
import { type ReactNode, memo } from 'react';
import { Box, Text } from '@/components';
const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const;
const WELCOME_MARGIN = {
horizontal: 'base',
bottom: 'md',
top: '-105px',
} as const;
const WELCOME_TEXT_MARGIN = { all: '0' } as const;
interface WelcomeLayoutProps {
title: string;
icon?: ReactNode;
}
export const WelcomeLayout = memo(function WelcomeLayout({
title,
icon,
}: WelcomeLayoutProps) {
return (
<Box $padding={WELCOME_PADDING} $align="center" $margin={WELCOME_MARGIN}>
<Box $direction="row" $align="center" $gap="10px">
{icon}
<Text as="h2" $size="xl" $weight="600" $margin={WELCOME_TEXT_MARGIN}>
{title}
</Text>
</Box>
</Box>
);
});
@@ -1,25 +1,10 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { WelcomeLayout } from './WelcomeLayout';
const WELCOME_PADDING = { all: 'base', bottom: 'md' } as const;
const WELCOME_MARGIN = {
horizontal: 'base',
bottom: 'md',
top: '-105px',
} as const;
const WELCOME_TEXT_MARGIN = { all: '0' } as const;
export const WelcomeMessage = memo(() => {
export const WelcomeMessage = memo(function WelcomeMessage() {
const { t } = useTranslation();
return (
<Box $padding={WELCOME_PADDING} $align="center" $margin={WELCOME_MARGIN}>
<Text as="h2" $size="xl" $weight="600" $margin={WELCOME_TEXT_MARGIN}>
{t('What is on your mind?')}
</Text>
</Box>
);
return <WelcomeLayout title={t('What is on your mind?')} />;
});
WelcomeMessage.displayName = 'WelcomeMessage';
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { AppWrapper } from '@/tests/utils';
import { InputChat } from '../InputChat';
@@ -94,7 +95,7 @@ describe('InputChat', () => {
});
it('should render the textarea', () => {
render(<InputChat {...defaultProps} />);
render(<InputChat {...defaultProps} />, { wrapper: AppWrapper });
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
@@ -102,31 +103,37 @@ describe('InputChat', () => {
});
it('should render welcome message when messagesLength is 0', () => {
render(<InputChat {...defaultProps} messagesLength={0} />);
render(<InputChat {...defaultProps} messagesLength={0} />, {
wrapper: AppWrapper,
});
expect(screen.getByTestId('welcome-message')).toBeInTheDocument();
});
it('should not render welcome message when messagesLength > 0', () => {
render(<InputChat {...defaultProps} messagesLength={1} />);
render(<InputChat {...defaultProps} messagesLength={1} />, {
wrapper: AppWrapper,
});
expect(screen.queryByTestId('welcome-message')).not.toBeInTheDocument();
});
it('should render suggestion carousel when input is empty', () => {
render(<InputChat {...defaultProps} input="" />);
render(<InputChat {...defaultProps} input="" />, { wrapper: AppWrapper });
expect(screen.getByTestId('suggestion-carousel')).toBeInTheDocument();
});
it('should not render suggestion carousel when input has content', () => {
render(<InputChat {...defaultProps} input="Hello" />);
render(<InputChat {...defaultProps} input="Hello" />, {
wrapper: AppWrapper,
});
expect(screen.queryByTestId('suggestion-carousel')).not.toBeInTheDocument();
});
it('should render input chat actions', () => {
render(<InputChat {...defaultProps} />);
render(<InputChat {...defaultProps} />, { wrapper: AppWrapper });
expect(screen.getByTestId('input-chat-actions')).toBeInTheDocument();
});
@@ -136,6 +143,7 @@ describe('InputChat', () => {
const handleInputChange = jest.fn();
render(
<InputChat {...defaultProps} handleInputChange={handleInputChange} />,
{ wrapper: AppWrapper },
);
const textarea = screen.getByRole('textbox', {
@@ -147,7 +155,9 @@ describe('InputChat', () => {
});
it('should disable textarea when status is not ready', () => {
render(<InputChat {...defaultProps} status="streaming" />);
render(<InputChat {...defaultProps} status="streaming" />, {
wrapper: AppWrapper,
});
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
@@ -155,7 +165,9 @@ describe('InputChat', () => {
});
it('should disable textarea when isUploadingFiles is true', () => {
render(<InputChat {...defaultProps} isUploadingFiles={true} />);
render(<InputChat {...defaultProps} isUploadingFiles={true} />, {
wrapper: AppWrapper,
});
expect(
screen.getByRole('textbox', { name: 'Enter your message or a question' }),
@@ -165,7 +177,9 @@ describe('InputChat', () => {
it('should submit form when pressing Enter', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />, {
wrapper: AppWrapper,
});
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
@@ -178,7 +192,9 @@ describe('InputChat', () => {
it('should not submit form when pressing Shift+Enter', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn((e) => e.preventDefault());
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />);
render(<InputChat {...defaultProps} handleSubmit={handleSubmit} />, {
wrapper: AppWrapper,
});
const textarea = screen.getByRole('textbox', {
name: 'Enter your message or a question',
@@ -1,5 +1,5 @@
/* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */
import { CunninghamProvider } from '@openfun/cunningham-react';
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { act, render, screen } from '@testing-library/react';
import { Suspense } from 'react';
@@ -1,5 +1,5 @@
/* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */
import { CunninghamProvider } from '@openfun/cunningham-react';
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Suspense } from 'react';
@@ -0,0 +1,102 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import {
InfiniteData,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import {
KEY_LIST_PROJECT,
ProjectsResponse,
} from '@/features/chat/api/useProjects';
import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore';
import { ChatProject } from '@/features/chat/types';
import { ProjectWelcomeMessage } from '../ProjectWelcomeMessage';
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
jest.mock('i18next', () => ({
t: (key: string) => key,
}));
const makeProject = (overrides?: Partial<ChatProject>): ChatProject => ({
id: 'proj-1',
title: 'Design System',
icon: 'folder',
color: 'color_6',
llm_instructions: '',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
conversations: [],
...overrides,
});
const renderWithCache = (
projectId: string | null,
cachedProjects: ChatProject[],
) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (cachedProjects.length > 0) {
const cacheData: InfiniteData<ProjectsResponse> = {
pages: [
{
count: cachedProjects.length,
results: cachedProjects,
next: null,
previous: null,
},
],
pageParams: [1],
};
queryClient.setQueryData([KEY_LIST_PROJECT, { page: 1 }], cacheData);
}
usePendingChatStore.setState({ projectId });
return render(
<QueryClientProvider client={queryClient}>
<CunninghamProvider>
<ProjectWelcomeMessage fallback={<span>Welcome fallback</span>} />
</CunninghamProvider>
</QueryClientProvider>,
);
};
describe('ProjectWelcomeMessage', () => {
beforeEach(() => {
usePendingChatStore.setState({
projectId: null,
input: '',
files: null,
hasProjectInstructions: false,
});
});
it('renders project title when projectId matches a cached project', () => {
renderWithCache('proj-1', [makeProject()]);
expect(screen.getByText('Design System')).toBeInTheDocument();
expect(screen.queryByText('Welcome fallback')).not.toBeInTheDocument();
});
it('renders fallback when no project found in cache', () => {
renderWithCache('proj-unknown', [makeProject()]);
expect(screen.getByText('Welcome fallback')).toBeInTheDocument();
expect(screen.queryByText('Design System')).not.toBeInTheDocument();
});
it('renders fallback when projectId is null', () => {
renderWithCache(null, [makeProject()]);
expect(screen.getByText('Welcome fallback')).toBeInTheDocument();
});
});
@@ -3,13 +3,34 @@ import { create } from 'zustand';
interface PendingChatState {
input: string;
files: FileList | null;
projectId: string | null;
hasProjectInstructions: boolean;
setPendingChat: (input: string, files: FileList | null) => void;
setProjectId: (id: string | null) => void;
setHasProjectInstructions: (value: boolean) => void;
clearPendingInput: () => void;
clearPendingChat: () => void;
}
export const usePendingChatStore = create<PendingChatState>((set) => ({
input: '',
files: null,
projectId: null,
hasProjectInstructions: false,
setPendingChat: (input, files) => set({ input, files }),
clearPendingChat: () => set({ input: '', files: null }),
setProjectId: (id) => set({ projectId: id }),
setHasProjectInstructions: (value) => set({ hasProjectInstructions: value }),
clearPendingInput: () =>
set({
input: '',
files: null,
hasProjectInstructions: false,
}),
clearPendingChat: () =>
set({
input: '',
files: null,
projectId: null,
hasProjectInstructions: false,
}),
}));
@@ -2,10 +2,32 @@ import { Message } from '@ai-sdk/react';
export type ChatMessage = Message;
export interface ChatConversationProject {
id: string;
title: string;
icon: string;
}
export interface ChatConversation {
id: string;
messages: ChatMessage[];
created_at: string;
updated_at: string;
title?: string;
project?: ChatConversationProject | null;
}
export interface ChatProjectConversation {
id: string;
title?: string;
}
export interface ChatProject {
id: string;
title: string;
created_at: string;
updated_at: string;
icon: string;
color: string;
llm_instructions: string;
conversations: ChatProjectConversation[];
}
@@ -4,7 +4,7 @@ import {
Modal,
ModalSize,
useModal,
} from '@openfun/cunningham-react';
} from '@gouvfr-lasuite/cunningham-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import LeftPanelIcon from '@/assets/icons/left-panel-bold.svg';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Icon } from '@/components/';
@@ -1,4 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/navigation';
import { useEffect as _useEffect, useState as _useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -1,6 +1,5 @@
// import { Gaufre } from '@gouvfr-lasuite/integration';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import '@gouvfr-lasuite/integration/dist/css/gaufre.css';
import { Button } from '@openfun/cunningham-react';
import Script from 'next/script';
import { useEffect } from 'react';
@@ -1,15 +1,13 @@
import { Button } from '@openfun/cunningham-react';
import { Button } from '@gouvfr-lasuite/cunningham-react';
import _Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
// import IconAssistant from '@/assets/logo/assistant.svg';
import IconAssistant from '@/assets/logo/logo-beta.svg';
import { Box, Icon, Text } from '@/components';
import { productName } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { gotoLogin } from '@/features/auth';
// import { ProConnectButton } from '@/features/auth';
import { useResponsiveStore } from '@/stores';
import BannerDarkMode from '../assets/banner-dark.svg';
@@ -19,7 +17,7 @@ import { getHeaderHeight } from './HomeHeader';
export default function HomeBanner() {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens, componentTokens, isDarkMode } =
const { theme, spacingsTokens, colorsTokens, componentTokens } =
useCunninghamTheme();
const { isMobile, isSmallMobile } = useResponsiveStore();
const withProConnect = Boolean(
@@ -108,7 +106,8 @@ export default function HomeBanner() {
</Button>
</Box>
</Box>
{!isMobile && (isDarkMode ? <BannerDarkMode /> : <Banner />)}
{!isMobile &&
(theme.includes('dark') ? <BannerDarkMode /> : <Banner />)}
</Box>
</Box>
);
@@ -3,14 +3,13 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { ChatConversation } from '@/features/chat/types';
import { useOwnModal } from '@/features/left-panel/hooks/useModalHook';
import { ModalRemoveConversation } from './ModalRemoveConversation';
import { ModalRenameConversation } from './ModalRenameConversation';
interface ConversationItemActionsProps {
conversation: ChatConversation;
conversation: { id: string; title?: string };
}
export const ConversationItemActions = ({
@@ -0,0 +1,92 @@
import { type ReactNode, memo, useCallback, useMemo } from 'react';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
import { useResponsiveStore } from '@/stores';
type ConversationRowProps = {
conversationId: string;
isActive: boolean;
children: ReactNode;
actions?: ReactNode;
};
const linkStyles = css`
overflow: auto;
flex-grow: 1;
color: var(--c--theme--colors--greyscale-900);
`;
const getBoxStyles = (isActive: boolean, isDesktop: boolean) => css`
border-radius: 4px;
width: 100%;
margin-bottom: 1px;
background-color: ${isActive
? 'var(--c--contextuals--background--semantic--overlay--primary)'
: 'transparent'};
font-weight: ${isActive ? '700' : '500'};
transition: background-color 0.2s cubic-bezier(1, 0, 0, 1);
.pinned-actions {
padding: 2px 0;
opacity: ${isDesktop ? 0 : 1};
background-color: transparent;
transition: all 0.3s cubic-bezier(1, 0, 0, 1);
}
&:hover,
&:focus,
&:focus-within {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
.pinned-actions {
opacity: 1;
}
}
.pinned-actions:focus-within {
opacity: 1;
}
`;
const containerPadding = { horizontal: 'xs', vertical: '4px' };
export const ConversationRow = memo(function ConversationRow({
conversationId,
isActive,
children,
actions,
}: ConversationRowProps) {
const isDesktop = useResponsiveStore((state) => state.isDesktop);
const setPanelOpen = useChatPreferencesStore((state) => state.setPanelOpen);
const handleLinkClick = useCallback(() => {
if (!isDesktop) {
setPanelOpen(false);
}
}, [isDesktop, setPanelOpen]);
const boxStyles = useMemo(
() => getBoxStyles(isActive, isDesktop),
[isActive, isDesktop],
);
return (
<Box
$direction="row"
$align="center"
$padding={containerPadding}
$justify="space-between"
$css={boxStyles}
>
<StyledLink
href={`/chat/${conversationId}/`}
$css={linkStyles}
onClick={handleLinkClick}
>
{children}
</StyledLink>
{actions && <Box className="pinned-actions">{actions}</Box>}
</Box>
);
});
@@ -1,106 +0,0 @@
import { memo, useCallback, useMemo } from 'react';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
import { ChatConversation } from '@/features/chat/types';
import { ConversationItemActions } from '@/features/left-panel/components/ConversationItemActions';
import { SimpleConversationItem } from '@/features/left-panel/components/SimpleConversationItem';
import { useResponsiveStore } from '@/stores';
type LeftPanelConversationItemProps = {
conversation: ChatConversation;
isCurrentConversation: boolean;
/** Affiche le temps depuis le dernier message (ex. dans la modale de recherche) */
showUpdatedAt?: boolean;
};
const linkStyles = css`
overflow: auto;
flex-grow: 1;
color: var(--c--theme--colors--greyscale-900);
`;
const getBoxStyles = (
isCurrentConversation: boolean,
isDesktop: boolean,
) => css`
border-radius: 4px;
width: 100%;
margin-bottom: 1px;
background-color: ${isCurrentConversation
? 'var(--c--contextuals--background--semantic--overlay--primary)'
: ''};
font-weight: ${isCurrentConversation ? '700' : '500'};
transition: background-color 0.2s cubic-bezier(1, 0, 0, 1);
.pinned-actions {
padding: 2px 0;
opacity: ${isDesktop ? 0 : 1};
background-color: transparent;
transition: all 0.3s cubic-bezier(1, 0, 0, 1);
}
&:hover,
&:focus,
&:focus-within {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
.pinned-actions {
opacity: 1;
}
}
.pinned-actions:focus-within {
opacity: 1;
}
`;
const containerPadding = { horizontal: 'xs', vertical: '4px' };
export const LeftPanelConversationItem = memo(
function LeftPanelConversationItem({
conversation,
isCurrentConversation,
showUpdatedAt = false,
}: LeftPanelConversationItemProps) {
const isDesktop = useResponsiveStore((state) => state.isDesktop);
const setPanelOpen = useChatPreferencesStore((state) => state.setPanelOpen);
const handleLinkClick = useCallback(() => {
if (!isDesktop) {
setPanelOpen(false);
}
}, [isDesktop, setPanelOpen]);
const boxStyles = useMemo(
() => getBoxStyles(isCurrentConversation, isDesktop),
[isCurrentConversation, isDesktop],
);
return (
<Box
$direction="row"
$align="center"
$padding={containerPadding}
$justify="space-between"
$css={boxStyles}
className="--docs--left-panel-favorite-item"
>
<StyledLink
href={`/chat/${conversation.id}/`}
$css={linkStyles}
onClick={handleLinkClick}
>
<SimpleConversationItem
showAccesses
showUpdatedAt={showUpdatedAt}
conversation={conversation}
/>
</StyledLink>
<Box className="pinned-actions">
<ConversationItemActions conversation={conversation} />
</Box>
</Box>
);
},
);
@@ -1,62 +0,0 @@
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Box, InfiniteScroll, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useInfiniteConversations } from '@/features/chat/api/useConversations';
import { LeftPanelConversationItem } from '@/features/left-panel/components/LeftPanelConversationItem';
export const LeftPanelConversations = () => {
const { t } = useTranslation();
const router = useRouter();
const { id } = router.query;
const { spacingsTokens } = useCunninghamTheme();
const conversations = useInfiniteConversations({
page: 1,
ordering: '-updated_at',
});
const favoriteConversations =
conversations.data?.pages.flatMap((page) => page.results) || [];
if (favoriteConversations.length === 0) {
return null;
}
return (
<Box>
<Box
$padding={{ horizontal: 'xs' }}
$gap={spacingsTokens['2xs']}
$height="50vh"
data-testid="left-panel-favorites"
>
<Text
$size="xs"
$theme="neutral"
$textTransform="uppercase"
$variation="tertiary"
$padding={{ horizontal: 'xs', bottom: '6px' }}
$weight="500"
>
{t('Your chats')}
</Text>
<InfiniteScroll
hasMore={conversations.hasNextPage}
isLoading={conversations.isFetchingNextPage}
next={() => void conversations.fetchNextPage()}
>
{favoriteConversations.map((conversation) => (
<LeftPanelConversationItem
key={conversation.id}
isCurrentConversation={conversation.id === id}
conversation={conversation}
/>
))}
</InfiniteScroll>
</Box>
</Box>
);
};
@@ -1,15 +1,13 @@
import { Button, Modal, ModalSize } from '@openfun/cunningham-react';
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { t } from 'i18next';
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import { Box, Text, useToast } from '@/components';
import { useRemoveConversation } from '@/features/chat/api/useRemoveConversation';
import { ChatConversation } from '@/features/chat/types';
interface ModalRemoveConversationProps {
onClose: () => void;
conversation: ChatConversation;
conversation: { id: string; title?: string };
}
export const ModalRemoveConversation = ({
@@ -17,8 +15,7 @@ export const ModalRemoveConversation = ({
conversation,
}: ModalRemoveConversationProps) => {
const { showToast } = useToast();
const { push } = useRouter();
const pathname = usePathname();
const { push, pathname } = useRouter();
const { mutate: removeDoc } = useRemoveConversation({
onSuccess: () => {
@@ -1,14 +1,18 @@
import { Button, Input, Modal, ModalSize } from '@openfun/cunningham-react';
import {
Button,
Input,
Modal,
ModalSize,
} from '@gouvfr-lasuite/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, useToast } from '@/components';
import { useRenameConversation } from '@/features/chat/api/useRenameConversation';
import { ChatConversation } from '@/features/chat/types';
interface ModalRenameConversationProps {
onClose: () => void;
conversation: ChatConversation;
conversation: { id: string; title?: string };
}
export const ModalRenameConversation = ({
@@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event';
import '@/i18n/initI18n';
import { LeftPanelConversationItem } from '../LeftPanelConversationItem';
import { LeftPanelConversationItem } from '../left-panel/LeftPanelConversationItem';
const mockSetPanelOpen = jest.fn();
const mockPush = jest.fn();
@@ -24,7 +24,7 @@ jest.mock('@/features/chat/stores/useChatPreferencesStore', () => ({
),
}));
jest.mock('../SimpleConversationItem', () => ({
jest.mock('@/features/left-panel/components/SimpleConversationItem', () => ({
SimpleConversationItem: ({
conversation,
}: {
@@ -32,7 +32,7 @@ jest.mock('../SimpleConversationItem', () => ({
}) => <div data-testid="simple-conversation-item">{conversation.title}</div>,
}));
jest.mock('../ConversationItemActions', () => ({
jest.mock('@/features/left-panel/components/ConversationItemActions', () => ({
ConversationItemActions: ({
conversation,
}: {
@@ -1,4 +1,4 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -1 +1 @@
export * from './LeftPanel';
export * from './left-panel/LeftPanel';
@@ -1,6 +1,7 @@
import { Box } from '@/components';
import { useAuth } from '@/features/auth/hooks';
import { LeftPanelConversations } from '@/features/left-panel/components/LeftPanelConversations';
import { LeftPanelConversations } from '@/features/left-panel/components/left-panel/LeftPanelConversations';
import { LeftPanelProjects } from '@/features/left-panel/components/left-panel/LeftPanelProjects';
import { useResponsiveStore } from '@/stores';
export const LeftPanelContent = () => {
@@ -17,7 +18,11 @@ export const LeftPanelContent = () => {
height: calc(100dvh - ${isDesktop ? '52px' : '104px'});
`}
>
{authenticated && <LeftPanelConversations />}
{authenticated && (
<>
<LeftPanelProjects /> <LeftPanelConversations />
</>
)}
</Box>
);
};
@@ -0,0 +1,35 @@
import { memo } from 'react';
import { ChatConversation } from '@/features/chat/types';
import { ConversationItemActions } from '@/features/left-panel/components/ConversationItemActions';
import { ConversationRow } from '@/features/left-panel/components/ConversationRow';
import { SimpleConversationItem } from '@/features/left-panel/components/SimpleConversationItem';
type LeftPanelConversationItemProps = {
conversation: ChatConversation;
isCurrentConversation: boolean;
/** Displays the time since the last message (e.g., in the search modal) */
showUpdatedAt?: boolean;
};
export const LeftPanelConversationItem = memo(
function LeftPanelConversationItem({
conversation,
isCurrentConversation,
showUpdatedAt = false,
}: LeftPanelConversationItemProps) {
return (
<ConversationRow
conversationId={conversation.id}
isActive={isCurrentConversation}
actions={<ConversationItemActions conversation={conversation} />}
>
<SimpleConversationItem
showAccesses
showUpdatedAt={showUpdatedAt}
conversation={conversation}
/>
</ConversationRow>
);
},
);
@@ -0,0 +1,60 @@
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Box, InfiniteScroll, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useInfiniteConversations } from '@/features/chat/api/useConversations';
import { LeftPanelConversationItem } from '@/features/left-panel/components/left-panel/LeftPanelConversationItem';
export const LeftPanelConversations = () => {
const { t } = useTranslation();
const router = useRouter();
const { id } = router.query;
const { spacingsTokens } = useCunninghamTheme();
const conversations = useInfiniteConversations({
page: 1,
ordering: '-updated_at',
});
const favoriteConversations =
conversations.data?.pages.flatMap((page) => page.results) || [];
if (favoriteConversations.length === 0) {
return null;
}
return (
<Box
$padding={{ horizontal: 'xs' }}
$gap={spacingsTokens['2xs']}
$height="50vh"
data-testid="left-panel-favorites"
>
<Text
$size="xs"
$theme="neutral"
$textTransform="uppercase"
$variation="tertiary"
$padding={{ horizontal: 'xs', bottom: '6px' }}
$weight="500"
>
{t('Your chats')}
</Text>
<InfiniteScroll
hasMore={conversations.hasNextPage}
isLoading={conversations.isFetchingNextPage}
next={() => void conversations.fetchNextPage()}
>
{favoriteConversations.map((conversation) => (
<LeftPanelConversationItem
key={conversation.id}
isCurrentConversation={conversation.id === id}
conversation={conversation}
/>
))}
</InfiniteScroll>
</Box>
);
};
@@ -1,11 +1,14 @@
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import { PropsWithChildren, useEffect, useState } from 'react';
import { Box } from '@/components';
import { useAuth } from '@/features/auth';
import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore';
import { useOwnModal } from '@/features/left-panel/hooks/useModalHook';
import { useResponsiveStore } from '@/stores';
import { ModalProjectForm } from '../projects/ModalProjectForm';
import { LeftPanelHeaderActions } from './LeftPanelHeaderActions';
import { LeftPanelSearchModal } from './LeftPanelSearchModal';
@@ -14,6 +17,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const { authenticated } = useAuth();
const { isDesktop } = useResponsiveStore();
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const createProjectModal = useOwnModal();
const { setPanelOpen } = useChatPreferencesStore();
@@ -29,7 +33,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
}, []);
const goToHome = () => {
router.push('/');
void router.push('/');
if (!isDesktop) {
setPanelOpen(false);
}
@@ -47,6 +51,7 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
}
setIsSearchModalOpen(true);
}}
onCreateProject={createProjectModal.open}
/>
{children}
</Box>
@@ -55,6 +60,9 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
{authenticated && isSearchModalOpen && (
<LeftPanelSearchModal onClose={() => setIsSearchModalOpen(false)} />
)}
{authenticated && createProjectModal.isOpen && (
<ModalProjectForm onClose={createProjectModal.close} />
)}
</>
);
};
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import NewChatIcon from '@/assets/icons/new-message-bold.svg';
import FolderPlusIcon from '@/assets/icons/uikit-custom/folder-plus.svg';
import { Box, Icon } from '@/components';
import { LeftPanelMenuItem } from './LeftPanelMenuItem';
@@ -20,11 +21,13 @@ const iconWrapperCss = css`
type LeftPanelHeaderActionsProps = {
onNewChat: () => void;
onSearch: () => void;
onCreateProject: () => void;
};
export const LeftPanelHeaderActions = ({
onNewChat,
onSearch,
onCreateProject,
}: LeftPanelHeaderActionsProps) => {
const { t } = useTranslation();
@@ -33,7 +36,7 @@ export const LeftPanelHeaderActions = ({
$direction="column"
$gap="2px"
$align="center"
$margin={{ vertical: 'base' }}
$margin={{ top: 'base' }}
$padding={{ horizontal: 'sm' }}
>
<LeftPanelMenuItem
@@ -56,6 +59,16 @@ export const LeftPanelHeaderActions = ({
onClick={onSearch}
aria-label={t('Search for a chat')}
/>
<LeftPanelMenuItem
icon={
<Box $css={iconWrapperCss} $display="flex" $align="center">
<FolderPlusIcon aria-hidden width="24" height="24" />
</Box>
}
label={t('New project')}
onClick={onCreateProject}
aria-label={t('New project')}
/>
</Box>
);
};
@@ -0,0 +1,197 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useRouter } from 'next/router';
import { memo, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore';
import { ChatProject } from '@/features/chat/types';
import { ConversationItemActions } from '@/features/left-panel/components/ConversationItemActions';
import { ConversationRow } from '@/features/left-panel/components/ConversationRow';
import { ProjectItemActions } from '@/features/left-panel/components/projects/ProjectItemActions';
import {
PROJECT_COLORS,
PROJECT_ICONS,
} from '@/features/left-panel/components/projects/project-constants';
type LeftPanelProjectItemProps = {
project: ChatProject;
currentConversationId?: string;
};
const headerStyles = css`
border-radius: 4px;
width: 100%;
cursor: pointer;
user-select: none;
transition: background-color 0.2s cubic-bezier(1, 0, 0, 1);
.pinned-actions {
opacity: 0;
transition: opacity 0.3s cubic-bezier(1, 0, 0, 1);
}
&:hover,
&:focus-within {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
.pinned-actions {
opacity: 1;
}
}
`;
const conversationListStyles = css`
overflow: hidden;
`;
const titleTextStyles = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const LeftPanelProjectItem = memo(function LeftPanelProjectItem({
project,
currentConversationId,
}: LeftPanelProjectItemProps) {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const router = useRouter();
const pendingProjectId = usePendingChatStore((s) => s.projectId);
const setProjectId = usePendingChatStore((s) => s.setProjectId);
const [isOpen, setIsOpen] = useState(false);
// Auto-open when current conversation belongs to this project or creating a new one in it
const belongsToProject =
currentConversationId &&
project.conversations.some((c) => c.id === currentConversationId);
const isTargetProject = belongsToProject || pendingProjectId === project.id;
useEffect(() => {
if (isTargetProject) {
setIsOpen(true);
}
}, [isTargetProject]);
const IconComponent = PROJECT_ICONS[project.icon] ?? PROJECT_ICONS.folder;
const iconColor =
colorsTokens[PROJECT_COLORS[project.color] as keyof typeof colorsTokens] ??
undefined;
const toggleOpen = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const handleNewConversation = useCallback(() => {
setProjectId(project.id);
void router.push('/chat/');
}, [router, project.id, setProjectId]);
return (
<Box>
{/* Project header */}
<Box
$direction="row"
$align="center"
$padding={{ horizontal: 'xs', vertical: '4px' }}
$gap="2px"
$justify="space-between"
$css={headerStyles}
>
<Box
$direction="row"
$align="center"
$gap="2px"
$css="min-width: 0; flex: 1; cursor: pointer;"
role="button"
tabIndex={0}
aria-expanded={isOpen}
aria-label={project.title}
onClick={toggleOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleOpen();
}
}}
>
<Icon
iconName={isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right'}
$theme="greyscale"
$variation="600"
$size="18px"
/>
<Box
$display="flex"
$align="center"
$justify="center"
$width="18px"
$height="18px"
style={{ color: iconColor, marginRight: '4px', flexShrink: 0 }}
>
<IconComponent
width={18}
height={18}
style={{ fill: 'currentColor', display: 'block' }}
/>
</Box>
<Text
$size="sm"
$variation="primary"
$weight="500"
$css={titleTextStyles}
>
{project.title}
</Text>
</Box>
<div
className="pinned-actions"
role="presentation"
style={{ display: 'flex', alignItems: 'center', gap: 0 }}
>
<ProjectItemActions project={project} />
<Button
aria-label={t('New conversation in project')}
onClick={handleNewConversation}
color="info"
variant="bordered"
size="nano"
icon={<Icon iconName="add" $size="18px" $theme="primary" />}
/>
</div>
</Box>
{/* Conversations list */}
{isOpen && (
<Box $padding={{ left: '28px' }} $css={conversationListStyles}>
{project.conversations.length === 0 ? (
<Text
$size="xs"
$variation="tertiary"
$padding={{ horizontal: 'xs', vertical: '4px' }}
>
{t('No conversations')}
</Text>
) : (
project.conversations.map((conv) => (
<ConversationRow
key={conv.id}
conversationId={conv.id}
isActive={conv.id === currentConversationId}
actions={<ConversationItemActions conversation={conv} />}
>
<Text $size="sm" $variation="primary" $weight="400">
{conv.title || t('Untitled conversation')}
</Text>
</ConversationRow>
))
)}
</Box>
)}
</Box>
);
});
@@ -0,0 +1,60 @@
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Box, InfiniteScroll, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useInfiniteProjects } from '@/features/chat/api/useProjects';
import { LeftPanelProjectItem } from '@/features/left-panel/components/left-panel/LeftPanelProjectItem';
export const LeftPanelProjects = () => {
const { t } = useTranslation();
const router = useRouter();
const { id } = router.query;
const { spacingsTokens } = useCunninghamTheme();
const projects = useInfiniteProjects({
page: 1,
});
const mainProjects =
projects.data?.pages.flatMap((page) => page.results) || [];
if (mainProjects.length === 0) {
return null;
}
return (
<Box
$padding={{ horizontal: 'xs' }}
$margin={{ vertical: 'base' }}
$gap={spacingsTokens['2xs']}
data-testid="left-panel-projects"
>
<Text
$size="xs"
$theme="neutral"
$textTransform="uppercase"
$variation="tertiary"
$padding={{ horizontal: 'xs', bottom: '6px' }}
$weight="500"
>
{t('Projects')}
</Text>
<InfiniteScroll
hasMore={projects.hasNextPage}
isLoading={projects.isFetchingNextPage}
next={() => void projects.fetchNextPage()}
>
{mainProjects.map((prj) => (
<LeftPanelProjectItem
key={prj.id}
currentConversationId={typeof id === 'string' ? id : undefined}
project={prj}
/>
))}
</InfiniteScroll>
</Box>
);
};
@@ -12,7 +12,7 @@ import {
} from '@/components/quick-search';
import { useInfiniteConversations } from '@/features/chat/api/useConversations';
import { ChatConversation } from '@/features/chat/types';
import { LeftPanelConversationItem } from '@/features/left-panel/components/LeftPanelConversationItem';
import { LeftPanelConversationItem } from '@/features/left-panel/components/left-panel/LeftPanelConversationItem';
import { useResponsiveStore } from '@/stores';
type LeftPanelSearchProps = {
@@ -1,4 +1,4 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { keepPreviousData } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -0,0 +1,116 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore';
import { ChatProject } from '@/features/chat/types';
import { LeftPanelProjectItem } from '../LeftPanelProjectItem';
const mockPush = jest.fn();
jest.mock('next/router', () => ({
useRouter: () => ({ push: mockPush }),
}));
jest.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
usePathname: () => '/chat/',
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
jest.mock('i18next', () => ({
t: (key: string) => key,
}));
// Stub child components that pull in heavy dependencies
jest.mock(
'@/features/left-panel/components/projects/ProjectItemActions',
() => ({
ProjectItemActions: () => null,
}),
);
jest.mock('@/features/left-panel/components/ConversationItemActions', () => ({
ConversationItemActions: () => null,
}));
const renderWithProviders = (component: React.ReactNode) =>
render(<CunninghamProvider>{component}</CunninghamProvider>);
const makeProject = (overrides?: Partial<ChatProject>): ChatProject => ({
id: 'proj-1',
title: 'Design System',
icon: 'folder',
color: 'color_6',
llm_instructions: '',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
conversations: [
{ id: 'conv-1', title: 'Colors discussion' },
{ id: 'conv-2', title: 'Typography' },
],
...overrides,
});
describe('LeftPanelProjectItem', () => {
beforeEach(() => {
jest.clearAllMocks();
usePendingChatStore.setState({
projectId: null,
input: '',
files: null,
hasProjectInstructions: false,
});
});
it('renders project title and starts collapsed', () => {
renderWithProviders(<LeftPanelProjectItem project={makeProject()} />);
expect(screen.getByText('Design System')).toBeInTheDocument();
expect(screen.queryByText('Colors discussion')).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Design System' }),
).toHaveAttribute('aria-expanded', 'false');
});
it('expands on click and shows conversations', async () => {
const user = userEvent.setup();
renderWithProviders(<LeftPanelProjectItem project={makeProject()} />);
await user.click(screen.getByRole('button', { name: 'Design System' }));
expect(screen.getByText('Colors discussion')).toBeInTheDocument();
expect(screen.getByText('Typography')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Design System' }),
).toHaveAttribute('aria-expanded', 'true');
});
it('auto-expands when currentConversationId matches a project conversation', () => {
renderWithProviders(
<LeftPanelProjectItem
project={makeProject()}
currentConversationId="conv-1"
/>,
);
expect(screen.getByText('Colors discussion')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Design System' }),
).toHaveAttribute('aria-expanded', 'true');
});
it('"New conversation" button sets projectId and navigates to /chat/', async () => {
const user = userEvent.setup();
renderWithProviders(<LeftPanelProjectItem project={makeProject()} />);
await user.click(
screen.getByRole('button', { name: 'New conversation in project' }),
);
expect(usePendingChatStore.getState().projectId).toBe('proj-1');
expect(mockPush).toHaveBeenCalledWith('/chat/');
});
});
@@ -0,0 +1,146 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants';
const iconEntries = Object.entries(PROJECT_ICONS);
const colorEntries = Object.entries(PROJECT_COLORS);
const gridCss = css`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 6px;
justify-items: center;
`;
const iconCellCss = css`
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
cursor: pointer;
border: none;
background-color: transparent;
color: var(--c--contextuals--content--semantic--neutral--secondary);
transition: background-color 0.15s ease;
& svg {
fill: currentColor;
}
&:hover {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
}
`;
const iconSelectedCss = css`
${iconCellCss}
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
`;
const colorCellCss = css`
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
cursor: pointer;
border: none;
background-color: transparent;
transition: background-color 0.15s ease;
&:hover {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
}
`;
const colorSelectedCss = css`
${colorCellCss}
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
`;
interface IconColorPickerProps {
icon: string;
color: string;
onIconChange: (icon: string) => void;
onColorChange: (color: string) => void;
}
export const ModalIconColorPicker = ({
icon,
color,
onIconChange,
onColorChange,
}: IconColorPickerProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$direction="column"
$gap="sm"
$padding={{ all: 'xs' }}
aria-label={t('Choose icon and color')}
>
<Box $css={gridCss}>
{iconEntries.map(([key, IconComp]) => (
<Box
key={key}
as="button"
type="button"
$css={icon === key ? iconSelectedCss : iconCellCss}
onClick={() => onIconChange(key)}
aria-label={key}
aria-pressed={icon === key}
>
<IconComp width={24} height={24} />
</Box>
))}
</Box>
<HorizontalSeparator $withPadding={false} />
<Box $css={gridCss}>
{colorEntries.map(([key, token]) => (
<Box
key={key}
as="button"
type="button"
$css={color === key ? colorSelectedCss : colorCellCss}
onClick={() => onColorChange(key)}
aria-label={key}
aria-pressed={color === key}
>
<Box
$css={css`
width: 32px;
height: 32px;
border-radius: 50%;
`}
style={{
backgroundColor:
(colorsTokens[
token as keyof typeof colorsTokens
] as string) ?? undefined,
}}
/>
</Box>
))}
</Box>
</Box>
);
};
@@ -0,0 +1,270 @@
import {
Button,
Input,
Modal,
ModalSize,
TextArea,
} from '@gouvfr-lasuite/cunningham-react';
import { useReducer } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, DropButton, Text, useToast } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useCreateProject } from '@/features/chat/api/useCreateProject';
import { useUpdateProject } from '@/features/chat/api/useUpdateProject';
import { ChatProject } from '@/features/chat/types';
import { ModalIconColorPicker } from './ModalIconColorPicker';
import { PROJECT_COLORS, PROJECT_ICONS } from './project-constants';
interface FormState {
name: string;
icon: string;
color: string;
instructions: string;
}
type FormAction =
| { type: 'SET_NAME'; value: string }
| { type: 'SET_ICON'; value: string }
| { type: 'SET_COLOR'; value: string }
| { type: 'SET_INSTRUCTIONS'; value: string };
const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case 'SET_NAME':
return { ...state, name: action.value };
case 'SET_ICON':
return { ...state, icon: action.value };
case 'SET_COLOR':
return { ...state, color: action.value };
case 'SET_INSTRUCTIONS':
return { ...state, instructions: action.value };
}
};
const avatarButtonCss = css`
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #ccc;
border-radius: 8px;
background-color: transparent;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.15s ease;
`;
interface ModalProjectFormProps {
project?: ChatProject;
onClose: () => void;
}
export const ModalProjectForm = ({
project,
onClose,
}: ModalProjectFormProps) => {
const isEditing = !!project;
const { showToast } = useToast();
const { t } = useTranslation();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const [form, dispatch] = useReducer(formReducer, {
name: project?.title ?? '',
icon: project?.icon || 'folder',
color: project?.color || 'color_6',
instructions: project?.llm_instructions || '',
});
const IconComp = PROJECT_ICONS[form.icon] ?? PROJECT_ICONS.folder;
const colorToken = PROJECT_COLORS[form.color];
const iconColor = colorToken
? (colorsTokens[colorToken as keyof typeof colorsTokens] ?? undefined)
: undefined;
const { mutate: createProject, isPending: isCreating } = useCreateProject({
onSuccess: () => {
showToast('success', t('The project has been created.'), undefined, 4000);
onClose();
},
onError: (error) => {
const errorMessage =
error.cause?.[0] ||
error.message ||
t('An error occurred while creating the project');
showToast('error', errorMessage, undefined, 4000);
},
});
const { mutate: updateProject, isPending: isUpdating } = useUpdateProject({
onSuccess: () => {
showToast('success', t('The project has been updated.'), undefined, 4000);
onClose();
},
onError: (error) => {
const errorMessage =
error.cause?.[0] ||
error.message ||
t('An error occurred while updating the project');
showToast('error', errorMessage, undefined, 4000);
},
});
const isPending = isCreating || isUpdating;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedName = form.name.trim();
if (!trimmedName) return;
const payload = {
title: trimmedName,
icon: form.icon,
color: form.color,
llm_instructions: form.instructions.trim(),
};
if (isEditing) {
updateProject({ projectId: project.id, ...payload });
} else {
createProject(payload);
}
};
const formId = isEditing ? 'project-settings-form' : 'create-project-form';
return (
<Modal
isOpen
closeOnClickOutside
onClose={onClose}
aria-label={
isEditing
? t('Project settings')
: t('Content modal to create a project')
}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="brand"
variant="bordered"
onClick={onClose}
>
{t('Cancel')}
</Button>
<Button
aria-label={
isEditing ? t('Save project settings') : t('New project')
}
color="brand"
variant="primary"
type="submit"
form={formId}
disabled={!form.name.trim() || isPending}
>
{isEditing ? t('Save') : t('New project')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
{isEditing ? t('Project settings') : t('New project')}
</Text>
}
>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<Box
className={
isEditing
? '--conversations--modal-project-settings'
: '--conversations--modal-create-project'
}
onKeyDown={(e) => e.stopPropagation()}
>
<form
onSubmit={handleSubmit}
id={formId}
data-testid={formId}
className="mt-s"
>
<Box $gap={spacingsTokens['base']} $direction="column">
<Box $direction="column">
<Text $size="sm" $variation="600" $weight="500">
{t('Project avatar and name')}
</Text>
<Box $direction="row" $align="center" $gap={spacingsTokens['xs']}>
<DropButton
button={
<Box $css={avatarButtonCss} style={{ color: iconColor }}>
<IconComp
width={24}
height={24}
style={{ fill: 'currentColor' }}
/>
</Box>
}
label={t('Choose icon and color')}
>
<ModalIconColorPicker
icon={form.icon}
color={form.color}
onIconChange={(value) =>
dispatch({ type: 'SET_ICON', value })
}
onColorChange={(value) =>
dispatch({ type: 'SET_COLOR', value })
}
/>
</DropButton>
<Input
type="text"
label={t('Project name')}
maxLength={100}
value={form.name}
fullWidth={true}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
dispatch({ type: 'SET_NAME', value: e.target.value })
}
required
/>
</Box>
</Box>
<Box $direction="column">
<TextArea
label={t('Instructions (experimental)')}
rows={6}
maxLength={4000} // cf. backend LLM_INSTRUCTIONS_MAX_LENGTH
value={form.instructions}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
dispatch({ type: 'SET_INSTRUCTIONS', value: e.target.value })
}
placeholder={t(
'Example: Be concise and maintain a professional tone.',
)}
/>
<Text $size="sm" $variation="600">
{t(
'Use this field to provide any additional context the Assistant may need. This feature is being tested. It may not work as expected.',
)}
</Text>
</Box>
</Box>
</form>
</Box>
</Modal>
);
};
@@ -0,0 +1,97 @@
import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/router';
import { Box, Text, useToast } from '@/components';
import { useRemoveProject } from '@/features/chat/api/useRemoveProject';
interface ModalRemoveProjectProps {
onClose: () => void;
project: { id: string; title: string };
}
export const ModalRemoveProject = ({
onClose,
project,
}: ModalRemoveProjectProps) => {
const { showToast } = useToast();
const { push, pathname } = useRouter();
const { mutate: removeProject, isPending: isRemoving } = useRemoveProject({
onSuccess: () => {
showToast('success', t('The project has been deleted.'), undefined, 4000);
if (pathname === '/') {
onClose();
} else {
void push('/');
}
},
onError: (error) => {
const errorMessage =
error.cause?.[0] ||
error.message ||
t('An error occurred while deleting the project');
showToast('error', errorMessage, undefined, 4000);
},
});
return (
<Modal
isOpen
closeOnClickOutside
onClose={() => onClose()}
aria-label={t('Content modal to delete project')}
rightActions={
<>
<Button
aria-label={t('Confirm deletion')}
color="error"
variant="bordered"
disabled={isRemoving}
fullWidth
onClick={() =>
removeProject({
projectId: project.id,
})
}
>
{t('Delete anyway')}
</Button>
<Button
aria-label={t('Close the modal')}
color="brand"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
</>
}
size={ModalSize.MEDIUM}
title={
<Text
$size="h6"
as="h6"
$margin={{ all: '0' }}
$align="flex-start"
$variation="1000"
>
{t('Delete {{title}}', { title: project.title })}
</Text>
}
>
<Box
className="--conversations--modal-remove-project"
data-testid="delete-project-confirm"
>
<Text $size="sm" $variation="600">
{t(
'Are you sure you want to delete the "{{title}}" project? All associated conversations and embedded' +
' documents will be permanently lost.',
{ title: project.title },
)}
</Text>
</Box>
</Modal>
);
};
@@ -0,0 +1,90 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { ChatProject } from '@/features/chat/types';
import { useOwnModal } from '@/features/left-panel/hooks/useModalHook';
import { ModalProjectForm } from './ModalProjectForm';
import { ModalRemoveProject } from './ModalRemoveProject';
interface ProjectItemActionsProps {
project: ChatProject;
}
export const ProjectItemActions = ({ project }: ProjectItemActionsProps) => {
const { t } = useTranslation();
const deleteModal = useOwnModal();
const settingsModal = useOwnModal();
const options: DropdownMenuOption[] = useMemo(
() => [
{
label: t('Project settings'),
icon: 'settings',
callback: settingsModal.open,
disabled: false,
testId: `project-item-actions-settings-${project.id}`,
},
{
label: t('Delete project'),
icon: 'delete',
callback: deleteModal.open,
disabled: false,
testId: `project-item-actions-remove-${project.id}`,
},
],
[t, project.id, settingsModal.open, deleteModal.open],
);
const dropdownLabel = useMemo(
() =>
t('Actions list for project {{title}}', {
title: project.title,
}),
[t, project.title],
);
return (
<>
<DropdownMenu
options={options}
label={dropdownLabel}
buttonCss={css`
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 4px;
border-radius: 4px;
&&:hover {
background-color: var(
--c--contextuals--background--semantic--overlay--primary
);
}
&:focus-visible {
outline: 2px solid
var(--c--contextuals--content--semantic--brand--tertiary);
outline-offset: 2px;
}
`}
>
<Icon
data-testid={`project-item-actions-button-${project.id}`}
iconName="more_horiz"
$theme="brand"
$variation="tertiary"
/>
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveProject onClose={deleteModal.close} project={project} />
)}
{settingsModal.isOpen && (
<ModalProjectForm onClose={settingsModal.close} project={project} />
)}
</>
);
};
@@ -0,0 +1,231 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { useToast } from '@/components';
import { ChatProject } from '@/features/chat/types';
import { ModalProjectForm } from '../ModalProjectForm';
jest.mock('@/components', () => ({
...jest.requireActual('@/components'),
useToast: jest.fn(),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
jest.mock('i18next', () => ({
t: (key: string) => key,
}));
const API_BASE = 'http://test.jest/api/v1.0/';
const renderWithProviders = (component: React.ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<CunninghamProvider>{component}</CunninghamProvider>
</QueryClientProvider>,
);
};
describe('ModalProjectForm', () => {
const mockOnClose = jest.fn();
const mockShowToast = jest.fn();
const existingProject: ChatProject = {
id: 'proj-123',
title: 'My Project',
icon: 'star',
color: 'color_3',
llm_instructions: 'Be helpful',
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
conversations: [],
};
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
(useToast as jest.Mock).mockReturnValue({
showToast: mockShowToast,
});
});
it('submits correct payload in create mode', async () => {
const user = userEvent.setup();
fetchMock.post(`${API_BASE}projects/`, {
status: 201,
body: {
id: 'new-id',
title: 'Test',
icon: 'folder',
color: 'color_6',
llm_instructions: '',
created_at: '',
updated_at: '',
conversations: [],
},
});
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
'Test',
);
await user.click(screen.getByRole('button', { name: 'New project' }));
await waitFor(() => expect(fetchMock.called()).toBe(true));
const [, options] = fetchMock.lastCall()!;
const body = JSON.parse(options!.body as string) as Record<string, unknown>;
expect(body).toEqual({
title: 'Test',
icon: 'folder',
color: 'color_6',
llm_instructions: '',
});
});
it('submits correct payload in edit mode', async () => {
const user = userEvent.setup();
fetchMock.patch(`${API_BASE}projects/proj-123/`, 200);
renderWithProviders(
<ModalProjectForm onClose={mockOnClose} project={existingProject} />,
);
const input = screen.getByRole('textbox', { name: 'Project name' });
await user.clear(input);
await user.type(input, 'Updated');
await user.click(
screen.getByRole('button', { name: 'Save project settings' }),
);
await waitFor(() => expect(fetchMock.called()).toBe(true));
const [url, options] = fetchMock.lastCall()!;
expect(url).toContain('projects/proj-123/');
const body = JSON.parse(options!.body as string) as Record<string, unknown>;
expect(body).toEqual({
title: 'Updated',
icon: 'star',
color: 'color_3',
llm_instructions: 'Be helpful',
});
});
it('disables submit button when name is empty or whitespace', async () => {
const user = userEvent.setup();
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
const submitButton = screen.getByRole('button', { name: 'New project' });
// Initially disabled (empty name)
expect(submitButton).toBeDisabled();
// Type whitespace only - still disabled
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
' ',
);
expect(submitButton).toBeDisabled();
});
it('shows success toast and closes modal on create', async () => {
const user = userEvent.setup();
fetchMock.post(`${API_BASE}projects/`, {
status: 201,
body: {
id: 'new-id',
title: 'Test',
icon: 'folder',
color: 'color_6',
llm_instructions: '',
created_at: '',
updated_at: '',
conversations: [],
},
});
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
'Test',
);
await user.click(screen.getByRole('button', { name: 'New project' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'success',
'The project has been created.',
undefined,
4000,
);
});
expect(mockOnClose).toHaveBeenCalled();
});
it('shows API error message on failed create', async () => {
const user = userEvent.setup();
fetchMock.post(`${API_BASE}projects/`, {
status: 400,
body: { title: ['A project with this title already exists.'] },
});
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
'Duplicate',
);
await user.click(screen.getByRole('button', { name: 'New project' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'error',
'A project with this title already exists.',
undefined,
4000,
);
});
expect(mockOnClose).not.toHaveBeenCalled();
});
it('shows error toast on network failure', async () => {
const user = userEvent.setup();
fetchMock.post(`${API_BASE}projects/`, {
throws: new Error('Network error'),
});
renderWithProviders(<ModalProjectForm onClose={mockOnClose} />);
await user.type(
screen.getByRole('textbox', { name: 'Project name' }),
'Test',
);
await user.click(screen.getByRole('button', { name: 'New project' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'error',
'Network error',
undefined,
4000,
);
});
expect(mockOnClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,142 @@
import { CunninghamProvider } from '@gouvfr-lasuite/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { useToast } from '@/components';
import { ModalRemoveProject } from '../ModalRemoveProject';
jest.mock('@/components', () => ({
...jest.requireActual('@/components'),
useToast: jest.fn(),
}));
const mockPush = jest.fn();
let mockPathname = '/';
jest.mock('next/router', () => ({
useRouter: () => ({ push: mockPush, pathname: mockPathname }),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, string>) => {
if (options) {
return Object.entries(options).reduce(
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
key,
);
}
return key;
},
}),
}));
jest.mock('i18next', () => ({
t: (key: string, options?: Record<string, string>) => {
if (options) {
return Object.entries(options).reduce(
(acc, [k, v]) => acc.replace(`{{${k}}}`, v),
key,
);
}
return key;
},
}));
const API_BASE = 'http://test.jest/api/v1.0/';
const renderWithProviders = (component: React.ReactNode) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<CunninghamProvider>{component}</CunninghamProvider>
</QueryClientProvider>,
);
};
describe('ModalRemoveProject', () => {
const mockOnClose = jest.fn();
const mockShowToast = jest.fn();
const project = { id: 'proj-123', title: 'My Project' };
beforeEach(() => {
jest.clearAllMocks();
fetchMock.restore();
mockPathname = '/';
(useToast as jest.Mock).mockReturnValue({
showToast: mockShowToast,
});
});
it('sends DELETE request on confirm button click', async () => {
const user = userEvent.setup();
fetchMock.delete(`${API_BASE}projects/proj-123/`, 204);
renderWithProviders(
<ModalRemoveProject onClose={mockOnClose} project={project} />,
);
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => expect(fetchMock.called()).toBe(true));
const [url, options] = fetchMock.lastCall()!;
expect(url).toContain('projects/proj-123/');
expect(options!.method).toBe('DELETE');
});
it('shows success toast and navigates home on success when not on home', async () => {
const user = userEvent.setup();
mockPathname = '/chat/some-id';
fetchMock.delete(`${API_BASE}projects/proj-123/`, 204);
renderWithProviders(
<ModalRemoveProject onClose={mockOnClose} project={project} />,
);
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'success',
'The project has been deleted.',
undefined,
4000,
);
});
expect(mockPush).toHaveBeenCalledWith('/');
expect(mockOnClose).not.toHaveBeenCalled();
});
it('shows success toast and closes modal when already on home', async () => {
// verify that when the user is already on /, the component
// doesn't redundantly navigate - it just closes modal and shows the toast
const user = userEvent.setup();
mockPathname = '/';
fetchMock.delete(`${API_BASE}projects/proj-123/`, 204);
renderWithProviders(
<ModalRemoveProject onClose={mockOnClose} project={project} />,
);
await user.click(screen.getByRole('button', { name: 'Confirm deletion' }));
await waitFor(() => {
expect(mockShowToast).toHaveBeenCalledWith(
'success',
'The project has been deleted.',
undefined,
4000,
);
});
expect(mockOnClose).toHaveBeenCalled();
expect(mockPush).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,63 @@
import { ComponentType, SVGProps } from 'react';
import BookIcon from '@/assets/icons/uikit-custom/book-filled.svg';
import BookmarkIcon from '@/assets/icons/uikit-custom/bookmark-filled.svg';
import CarIcon from '@/assets/icons/uikit-custom/car-filled.svg';
import ChartIcon from '@/assets/icons/uikit-custom/chart-filled.svg';
import CheckmarkIcon from '@/assets/icons/uikit-custom/checkmark-filled.svg';
import EuroIcon from '@/assets/icons/uikit-custom/euro-filled.svg';
import FileIcon from '@/assets/icons/uikit-custom/file-filled.svg';
import FolderIcon from '@/assets/icons/uikit-custom/folder-filled.svg';
import GearIcon from '@/assets/icons/uikit-custom/gear-rounded-filled.svg';
import JusticeIcon from '@/assets/icons/uikit-custom/justice-filled.svg';
import KeyIcon from '@/assets/icons/uikit-custom/key-filled.svg';
import LaSuiteIcon from '@/assets/icons/uikit-custom/lasuite-filled.svg';
import MegaphoneIcon from '@/assets/icons/uikit-custom/megaphone-filled.svg';
import MusicIcon from '@/assets/icons/uikit-custom/music-filled.svg';
import PaletteIcon from '@/assets/icons/uikit-custom/palette-filled.svg';
import PersoIcon from '@/assets/icons/uikit-custom/perso-filled.svg';
import PhotoIcon from '@/assets/icons/uikit-custom/picture-filled.svg';
import PuzzleIcon from '@/assets/icons/uikit-custom/puzzle-filled.svg';
import StarIcon from '@/assets/icons/uikit-custom/star-filled.svg';
import TerminalIcon from '@/assets/icons/uikit-custom/terminal-filled.svg';
export const PROJECT_COLORS: Record<string, string> = {
color_1: 'red-500',
color_2: 'warning-400',
color_3: 'orange-500',
color_4: 'brown-350',
color_5: 'green-650',
color_6: 'blue-1-500',
color_7: 'blue-2-500',
color_8: 'pink-300',
color_9: 'yellow-500',
color_10: 'purple-500',
};
export const defaultIconColor = 'blue-1-500';
export const PROJECT_ICONS: Record<
string,
ComponentType<SVGProps<SVGSVGElement>>
> = {
folder: FolderIcon,
file: FileIcon,
perso: PersoIcon,
gear: GearIcon,
megaphone: MegaphoneIcon,
star: StarIcon,
bookmark: BookmarkIcon,
chart: ChartIcon,
photo: PhotoIcon,
euro: EuroIcon,
key: KeyIcon,
justice: JusticeIcon,
book: BookIcon,
puzzle: PuzzleIcon,
palette: PaletteIcon,
terminal: TerminalIcon,
car: CarIcon,
music: MusicIcon,
checkmark: CheckmarkIcon,
la_suite: LaSuiteIcon,
};

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