Compare commits

...

11 Commits

Author SHA1 Message Date
Laurent Paoletti 402f7a7487 (front) copy button preserves formatting when pasting into Word
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-24 18:48:39 +01:00
Laurent Paoletti f5a1b72913 🐛(back) add missing color option for project colors
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-24 15:12:56 +01:00
Laurent Paoletti 9e37f71818 ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-23 18:04:22 +01: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
Laurent Paoletti bcf55c6c0c (back) add projects with custom LLM instructions
Introduce the project concept for conversations:
- Add nested project serializer for search results (`?title=` returns
project id, title, icon)
- Inject project-level custom LLM instructions into the AI agent systemprompt
- Document `title` and `project` query parameters in OpenAPI schema
- Fix ChatProjectFactory deprecation warning
- Add db compound indexes on ChatConversation for and ChatProject to speed
up sidebar queries

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-16 09:50:10 +01:00
Eléonore Voisin 2f74543d41 (search) add searchModal
fix ui, fix settings button
2026-03-12 17:53:19 +01:00
Laurent Paoletti 995ad407a6 (search) add search modal with improved UX
- Add search modal and modify left panel layout
- Fix result list flashing by keeping previous data during refetch
- Defer loader display to avoid search icon flickering on fast responses
- Add Cmd+K / Ctrl+K shortcut to toggle search modal
- Auto-focus search input on modal open
- Require minimum 3 characters before triggering search
- Only mount modal when authenticated and open
- Extract shared getRelativeTime utility
- Remove unused controlled selection state in QuickSearch

Co-Authored-By: Eléonore Voisin <elevoisin@gmail.com
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-12 16:31:03 +01:00
providenz 0746d23a1e 🌐(i18n) update translated strings
Update translated files with new translations

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-12 09:17:35 +01:00
Laurent Paoletti c699d7503d 🔖(patch) bump release to 0.0.14
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-11 16:20:40 +01:00
Laurent Paoletti bc7568ccf1 ⬆️(back) update django and pypdf
Signed-off-by: Laurent Paoletti <lp@providenz.fr>
2026-03-11 15:12:22 +01:00
184 changed files with 6548 additions and 2697 deletions
+1
View File
@@ -1,6 +1,7 @@
[codespell]
skip =
./git/,
**/*.pdf,
**/*.po,
**/*.pot,
**/*.json,
+21 -6
View File
@@ -10,7 +10,24 @@ and this project adheres to
### Added
- ✨(back) add projects with custom LLM instructions
- ✨(front) projects management UI
### Changed
- ⬆️(dependencies) upgrade Next.js 15 to 16, upgrade python dependencies
- ✨(front) rich text copy for Word/Docs paste
### Fixed
- 🐛(back) add missing color option for project colors
## [0.0.14] - 2026-03-11
### Added
- ✨(user) allow disabling automatic internet search
- ✨(search) add searchModal and modify leftPanel
- ✨(waffle) hide the waffle if not fr theme
- ✨(front) allow pasting an attachment from clipboard
- ✨(array) temporarily adjust array
@@ -23,10 +40,10 @@ and this project adheres to
- 🚸(oidc) ignore case when fallback on email #281
- ⬆️(back) update pillow, django-pydantic-field, pypdf
- ♻️(front) migrate from ESLint 8 to ESLint 9 flat config
- ⬆️(back) update django and pypdf
### Fixed
- 💚(docker) vendor mime.types file instead of fetching from Apache SVN
- 🚑️(back) fix mime type for pptx
- 🐛(front) fix math formulas and carousel translations
@@ -54,7 +71,7 @@ and this project adheres to
### Fixed
- 🐛(back) cast collection Ids to API expected types
- 🐛(back) cast collection Ids to API expected types
## [0.0.12] - 2026-01-27
@@ -74,7 +91,6 @@ and this project adheres to
- 📦️(front) update react
- ✨(chat) generate and edit conversation title
### Fixed
- 🐛(e2e) fix test-e2e-chromium
@@ -82,7 +98,6 @@ and this project adheres to
- ⚰️(back) remove dead code and unused files
- 🐛(back) prevent tool call timeouts
### Removed
- 🔥(chat) remove thinking part from frontend #227
@@ -240,8 +255,8 @@ and this project adheres to
- ✨(onboarding) add activation code logic for launch #62
- 💄(chat) add code highlighting for LLM responses #67
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.13...main
[unreleased]: https://github.com/suitenumerique/conversations/compare/v0.0.14...main
[0.0.14]: https://github.com/suitenumerique/conversations/releases/v0.0.14
[0.0.13]: https://github.com/suitenumerique/conversations/releases/v0.0.13
[0.0.12]: https://github.com/suitenumerique/conversations/releases/v0.0.12
[0.0.11]: https://github.com/suitenumerique/conversations/releases/v0.0.11
+18 -1
View File
@@ -9,11 +9,28 @@ from . import models
class ChatConversationAdmin(admin.ModelAdmin):
"""Admin class for the ChatConversation model"""
autocomplete_fields = ("owner",)
autocomplete_fields = ("owner", "project")
list_select_related = ("project",)
list_display = (
"id",
"title",
"project",
"created_at",
"updated_at",
)
@admin.register(models.ChatProject)
class ChatProjectAdmin(admin.ModelAdmin):
"""Admin class for the ChatProject model"""
search_fields = ("title",)
list_display = (
"id",
"title",
"icon",
"color",
"created_at",
"updated_at",
)
+14
View File
@@ -10,6 +10,8 @@ changes are needed in views.py or tests.
1. **Initialization**: `AIAgentService` is created with a conversation and user.
Feature flags, model config, and the conversation agent are set up.
If the conversation belongs to a project with custom LLM instructions,
they are injected as a dynamic agent instruction.
2. **Streaming Entry Points**: `stream_text()` or `stream_data()` are called
with user messages. These wrap async generators for sync consumption.
@@ -263,6 +265,18 @@ class AIAgentService: # pylint: disable=too-many-instance-attributes
)
add_document_rag_search_tool_from_setting(self.conversation_agent, self.user)
# Inject project-level custom instructions if the conversation belongs to a project
llm_user_instructions = (
conversation.project.llm_instructions.strip()
if conversation.project_id is not None
else ""
)
if llm_user_instructions:
@self.conversation_agent.instructions
def project_instructions() -> str:
return llm_user_instructions
@property
def _stop_cache_key(self):
return f"streaming:stop:{self.conversation.pk}"
+23
View File
@@ -9,6 +9,29 @@ from core.factories import UserFactory
from . import models
class ChatProjectFactory(factory.django.DjangoModelFactory):
"""Factory for creating Project instances."""
title = factory.Sequence(lambda n: f"title {n}")
owner = factory.SubFactory(UserFactory)
icon = factory.fuzzy.FuzzyChoice(models.ChatProjectIcon)
color = factory.fuzzy.FuzzyChoice(models.ChatProjectColor)
class Meta:
model = models.ChatProject
skip_postgeneration_save = True
@factory.post_generation
def number_of_conversations(self, create, extracted, **kwargs):
"""Create attached conversations for the project."""
if not create or not extracted:
return
if not isinstance(extracted, int):
raise TypeError("number_of_conversations must be an integer")
ChatConversationFactory.create_batch(extracted, project=self, owner=self.owner)
class ChatConversationFactory(factory.django.DjangoModelFactory):
"""Factory for creating ChatConversation instances."""
@@ -0,0 +1,136 @@
# Generated by Django 5.2.11 on 2026-02-25 16:20
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat", "0005_chatconversation_title_set_by_user_at"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ChatProject",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="primary key for the record as UUID",
primary_key=True,
serialize=False,
verbose_name="id",
),
),
(
"created_at",
models.DateTimeField(
auto_now_add=True,
help_text="date and time at which a record was created",
verbose_name="created on",
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True,
help_text="date and time at which a record was last updated",
verbose_name="updated on",
),
),
("title", models.CharField(help_text="Title of the chat project", max_length=100)),
(
"icon",
models.CharField(
choices=[
("folder", "Folder icon"),
("file", "File icon"),
("perso", "Perso icon"),
("gear", "Gear icon"),
("megaphone", "Megaphone icon"),
("star", "Star icon"),
("bookmark", "Bookmark icon"),
("chart", "Chart icon"),
("photo", "Photo icon"),
("euro", "Euro icon"),
("key", "Key icon"),
("justice", "Justice icon"),
("book", "Book icon"),
("puzzle", "Puzzle icon"),
("palette", "Palette icon"),
("terminal", "Terminal icon"),
("car", "Car icon"),
("music", "Music icon"),
("checkmark", "Checkmark icon"),
("la_suite", "La Suite icon"),
],
help_text="Project icon",
max_length=20,
),
),
(
"color",
models.CharField(
choices=[
("color_1", "Color 1"),
("color_2", "Color 2"),
("color_3", "Color 3"),
("color_4", "Color 4"),
("color_5", "Color 5"),
("color_6", "Color 6"),
("color_7", "Color 7"),
("color_8", "Color 8"),
("color_9", "Color 9"),
("color_10", "Color 10"),
],
help_text="Project icon color",
max_length=20,
),
),
(
"llm_instructions",
models.TextField(
blank=True, help_text="Custom user instructions to be sent to the llm"
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="projects",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="chatconversation",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="conversations",
to="chat.chatproject",
),
),
migrations.AddIndex(
model_name="chatconversation",
index=models.Index(
fields=["owner", "-created_at"], name="chat_chatco_owner_i_48266a_idx"
),
),
migrations.AddIndex(
model_name="chatconversation",
index=models.Index(fields=["owner", "project"], name="chat_chatco_owner_i_38d719_idx"),
),
]
+85
View File
@@ -15,6 +15,74 @@ from chat.ai_sdk_types import UIMessage
User = get_user_model()
class ChatProjectIcon(models.TextChoices):
"""Project icon text choices."""
FOLDER = "folder", "Folder icon"
FILE = "file", "File icon"
PERSO = "perso", "Perso icon"
GEAR = "gear", "Gear icon"
MEGAPHONE = "megaphone", "Megaphone icon"
STAR = "star", "Star icon"
BOOKMARK = "bookmark", "Bookmark icon"
CHART = "chart", "Chart icon"
PHOTO = "photo", "Photo icon"
EURO = "euro", "Euro icon"
KEY = "key", "Key icon"
JUSTICE = "justice", "Justice icon"
BOOK = "book", "Book icon"
PUZZLE = "puzzle", "Puzzle icon"
PALETTE = "palette", "Palette icon"
TERMINAL = "terminal", "Terminal icon"
CAR = "car", "Car icon"
MUSIC = "music", "Music icon"
CHECKMARK = "checkmark", "Checkmark icon"
LA_SUITE = "la_suite", "La Suite icon"
class ChatProjectColor(models.TextChoices):
"""Project icon color choices. We keep it generic to ease frontend compatibility."""
COLOR_1 = "color_1", "Color 1"
COLOR_2 = "color_2", "Color 2"
COLOR_3 = "color_3", "Color 3"
COLOR_4 = "color_4", "Color 4"
COLOR_5 = "color_5", "Color 5"
COLOR_6 = "color_6", "Color 6"
COLOR_7 = "color_7", "Color 7"
COLOR_8 = "color_8", "Color 8"
COLOR_9 = "color_9", "Color 9"
COLOR_10 = "color_10", "Color 10"
class ChatProject(BaseModel):
"""Model representing a project that groups conversations together."""
owner = models.ForeignKey(
User,
related_name="projects",
on_delete=models.CASCADE,
null=False,
blank=False,
)
title = models.CharField(
max_length=100,
help_text="Title of the chat project",
)
icon = models.CharField(max_length=20, choices=ChatProjectIcon, help_text="Project icon")
color = models.CharField(
max_length=20, choices=ChatProjectColor, help_text="Project icon color"
)
llm_instructions = models.TextField(
blank=True,
help_text="Custom user instructions to be sent to the llm",
)
def __str__(self):
return self.title
class ChatConversation(BaseModel):
"""
Model representing a chat conversation.
@@ -79,6 +147,23 @@ class ChatConversation(BaseModel):
help_text="Collection ID for the conversation, used for RAG document search",
)
project = models.ForeignKey(
ChatProject,
related_name="conversations",
on_delete=models.SET_NULL, # explicitly avoid Cascade here
null=True,
blank=True,
)
class Meta: # pylint: disable=missing-class-docstring
indexes = [
models.Index(fields=["owner", "-created_at"]),
models.Index(fields=["owner", "project"]),
]
def __str__(self):
return self.title or str(self.pk)
class ChatConversationAttachment(BaseModel):
"""
+77 -1
View File
@@ -25,10 +25,21 @@ class ChatConversationSerializer(serializers.ModelSerializer):
class Meta: # pylint: disable=missing-class-docstring
model = models.ChatConversation
fields = ["id", "title", "created_at", "updated_at", "messages", "owner"]
fields = ["id", "title", "created_at", "updated_at", "messages", "owner", "project"]
read_only_fields = ["id", "created_at", "updated_at", "messages"]
def validate_project(self, project):
"""Ensure the project belongs to the current user."""
if project and project.owner != self.context["request"].user:
raise serializers.ValidationError("The project must belong to the current user.")
return project
def update(self, instance, validated_data):
# Project is immutable after creation — no moving or detaching
if "project" in validated_data:
raise serializers.ValidationError(
{"project": "This field can only be set at creation time."}
)
# If title is being changed, mark it as user-set
if "title" in validated_data and validated_data["title"] != instance.title:
instance.title_set_by_user_at = timezone.now()
@@ -216,3 +227,68 @@ class CreateChatConversationAttachmentSerializer(serializers.ModelSerializer):
)
return size
class ChatProjectNestedSerializer(serializers.ModelSerializer):
"""Lightweight read-only serializer for nested project info in search results."""
class Meta: # pylint: disable=missing-class-docstring
model = models.ChatProject
fields = ["id", "title", "icon"]
read_only_fields = ["id", "title", "icon"]
class ChatConversationSearchSerializer(serializers.ModelSerializer):
"""Serializer for conversation search results with nested project info."""
project = ChatProjectNestedSerializer(read_only=True)
class Meta: # pylint: disable=missing-class-docstring
model = models.ChatConversation
fields = ["id", "title", "created_at", "updated_at", "project"]
read_only_fields = ["id", "title", "created_at", "updated_at", "project"]
class ChatConversationNestedSerializer(serializers.ModelSerializer):
"""Serializer for chat conversations."""
class Meta: # pylint: disable=missing-class-docstring
model = models.ChatConversation
fields = [
"id",
"title",
]
read_only_fields = ["id", "title"]
class ChatProjectSerializer(serializers.ModelSerializer):
"""Serializer for projects."""
LLM_INSTRUCTIONS_MAX_LENGTH = 4000 # prevent too large prompts, easier to handle here
owner = serializers.HiddenField(default=serializers.CurrentUserDefault())
# Unbounded: the sidebar needs all conversations per project.
# Projects are paginated at the view level, keeping payloads reasonable.
conversations = ChatConversationNestedSerializer(many=True, read_only=True)
llm_instructions = serializers.CharField(
max_length=LLM_INSTRUCTIONS_MAX_LENGTH, required=False, allow_blank=True
)
class Meta: # pylint: disable=missing-class-docstring
model = models.ChatProject
fields = [
"id",
"title",
"created_at",
"updated_at",
"icon",
"color",
"llm_instructions",
"owner",
"conversations",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
]
@@ -0,0 +1,60 @@
"""Unit tests for project-level LLM instructions injection."""
import pytest
from chat.clients.pydantic_ai import AIAgentService
from chat.factories import ChatConversationFactory, ChatProjectFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def base_settings(settings):
"""Set up base settings for the tests."""
settings.AI_BASE_URL = "https://api.llm.com/v1/"
settings.AI_API_KEY = "test-key"
settings.AI_MODEL = "model-123"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful assistant"
settings.AI_AGENT_TOOLS = []
def _get_instruction_names(service):
"""Return the names of dynamic (callable) instructions registered on the conversation agent."""
# pylint: disable=protected-access
return [fn.__name__ for fn in service.conversation_agent._instructions if callable(fn)]
def test_project_instructions_injected_when_present():
"""Test that project LLM instructions are injected as a dynamic agent instruction."""
project = ChatProjectFactory(llm_instructions="Always answer in bullet points.")
conversation = ChatConversationFactory(owner=project.owner, project=project)
service = AIAgentService(conversation, user=conversation.owner)
assert "project_instructions" in _get_instruction_names(service)
# Verify the instruction returns the correct content
instruction_fn = next(
fn
for fn in service.conversation_agent._instructions # pylint: disable=protected-access
if callable(fn) and fn.__name__ == "project_instructions"
)
assert instruction_fn() == "Always answer in bullet points."
def test_project_instructions_not_injected_when_empty():
"""Test that empty project instructions are not injected."""
project = ChatProjectFactory(llm_instructions="")
conversation = ChatConversationFactory(owner=project.owner, project=project)
service = AIAgentService(conversation, user=conversation.owner)
assert "project_instructions" not in _get_instruction_names(service)
def test_project_instructions_not_injected_when_no_project():
"""Test that no project instruction is injected for standalone conversations."""
conversation = ChatConversationFactory()
service = AIAgentService(conversation, user=conversation.owner)
assert "project_instructions" not in _get_instruction_names(service)
@@ -0,0 +1 @@
"""Tests for chat factories."""
@@ -0,0 +1,25 @@
"""Unit tests for the ChatConversationFactory."""
import pytest
from chat.factories import ChatConversationFactory, ChatProjectFactory
pytestmark = pytest.mark.django_db
def test_conversation_factory():
"""Test that the factory creates a valid conversation with default values."""
conversation = ChatConversationFactory()
assert conversation.owner is not None
assert conversation.title is None
assert conversation.project is None
def test_conversation_factory_with_project():
"""Test that the factory accepts a project."""
project = ChatProjectFactory()
conversation = ChatConversationFactory(project=project, owner=project.owner)
assert conversation.project == project
assert conversation.owner == project.owner
@@ -0,0 +1,26 @@
"""Unit tests for the ChatProjectFactory."""
import pytest
from chat.factories import ChatProjectFactory
pytestmark = pytest.mark.django_db
def test_project_factory():
"""Test that the factory creates a valid project with default values."""
project = ChatProjectFactory()
assert project.title.startswith("title ")
assert project.icon
assert project.color
assert project.owner is not None
assert project.conversations.count() == 0
def test_project_factory_number_of_conversations():
"""Test that number_of_conversations creates attached conversations."""
project = ChatProjectFactory(number_of_conversations=3)
assert project.conversations.count() == 3
assert all(c.owner == project.owner for c in project.conversations.all())
@@ -0,0 +1,152 @@
"""Unit tests for the ChatProjectSerializer."""
import pytest
from rest_framework.test import APIRequestFactory
from core.factories import UserFactory
from chat import serializers
from chat.factories import ChatConversationFactory, ChatProjectFactory
from chat.models import ChatProjectColor, ChatProjectIcon
pytestmark = pytest.mark.django_db
@pytest.fixture(name="request_context")
def request_context_fixture():
"""Return a serializer context with an authenticated request."""
user = UserFactory()
request = APIRequestFactory().post("/")
request.user = user
return {"request": request}
def test_serialize_project():
"""Test serializing a project returns expected fields."""
project = ChatProjectFactory(
title="My project",
llm_instructions="My custom instructions",
color=ChatProjectColor.COLOR_2,
icon=ChatProjectIcon.JUSTICE,
)
serializer = serializers.ChatProjectSerializer(project)
data = serializer.data
assert data["title"] == "My project"
assert data["id"] == str(project.pk)
assert data["icon"] == "justice"
assert data["color"] == "color_2"
assert data["llm_instructions"] == "My custom instructions"
assert data["conversations"] == []
assert "created_at" in data
assert "updated_at" in data
def test_serialize_project_with_conversations():
"""Test serializing a project includes nested conversations."""
project = ChatProjectFactory()
conversation = ChatConversationFactory(
project=project, owner=project.owner, title="My conversation"
)
serializer = serializers.ChatProjectSerializer(project)
data = serializer.data
assert len(data["conversations"]) == 1
assert data["conversations"][0] == {
"id": str(conversation.pk),
"title": "My conversation",
}
def test_deserialize_valid_project(request_context):
"""Test deserializing valid project data."""
data = {
"title": "My Project",
"icon": "star",
"color": "color_2",
}
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
assert serializer.is_valid()
assert serializer.validated_data["title"] == "My Project"
assert serializer.validated_data["icon"] == ChatProjectIcon.STAR
assert serializer.validated_data["color"] == ChatProjectColor.COLOR_2
def test_deserialize_missing_title(request_context):
"""Test that title is required."""
data = {
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
assert not serializer.is_valid()
assert "title" in serializer.errors
def test_deserialize_invalid_icon(request_context):
"""Test that an invalid icon value is rejected."""
data = {
"title": "My Project",
"icon": "invalid_icon",
"color": ChatProjectColor.COLOR_1,
}
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
assert not serializer.is_valid()
assert "icon" in serializer.errors
def test_deserialize_invalid_color(request_context):
"""Test that an invalid color value is rejected."""
data = {
"title": "My Project",
"icon": ChatProjectIcon.FOLDER,
"color": "invalid_color",
}
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
assert not serializer.is_valid()
assert "color" in serializer.errors
def test_deserialize_title_max_length(request_context):
"""Test that a title exceeding 100 characters is rejected."""
data = {
"title": "X" * 101,
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
assert not serializer.is_valid()
assert "title" in serializer.errors
def test_conversations_field_is_read_only(request_context):
"""Test that conversations cannot be set via input data."""
data = {
"title": "My Project",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
"conversations": [{"id": "fake", "title": "fake"}],
}
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
assert serializer.is_valid()
assert "conversations" not in serializer.validated_data
def test_owner_is_set_from_request(request_context):
"""Test that the owner is automatically set from the request user."""
data = {
"title": "My Project",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
serializer = serializers.ChatProjectSerializer(data=data, context=request_context)
assert serializer.is_valid()
project = serializer.save()
assert project.owner == request_context["request"].user
@@ -0,0 +1,112 @@
"""Unit tests for conversation with project LLM instructions."""
import json
import pytest
import respx
from freezegun import freeze_time
from rest_framework import status
from chat.factories import ChatConversationFactory, ChatProjectFactory
from chat.tests.utils import replace_uuids_with_placeholder
pytestmark = pytest.mark.django_db(transaction=True)
@pytest.fixture(autouse=True)
def ai_settings(settings):
"""Fixture to set AI service URLs for testing."""
settings.AI_BASE_URL = "https://www.external-ai-service.com/"
settings.AI_API_KEY = "test-api-key"
settings.AI_MODEL = "test-model"
settings.AI_AGENT_INSTRUCTIONS = "You are a helpful test assistant :)"
return settings
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_includes_project_llm_instructions(api_client, mock_openai_stream):
"""Test that project LLM instructions are sent to the LLM as part of the system prompt."""
project = ChatProjectFactory(llm_instructions="Always reply in bullet points.")
conversation = ChatConversationFactory(
owner=project.owner, project=project, owner__language="en-us"
)
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "msg-1",
"role": "user",
"parts": [{"text": "Hello", "type": "text"}],
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
}
]
}
api_client.force_login(conversation.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
last_request_payload = json.loads(respx.calls.last.request.content)
system_message = last_request_payload["messages"][0]
assert system_message["role"] == "system"
assert "Always reply in bullet points." in system_message["content"]
assert mock_openai_stream.called
@freeze_time("2025-07-25T10:36:35.297675Z")
@respx.mock
def test_post_conversation_without_project_has_no_project_instructions(
api_client, mock_openai_stream
):
"""Test that conversations without a project do not include project instructions."""
conversation = ChatConversationFactory(owner__language="en-us")
url = f"/api/v1.0/chats/{conversation.pk}/conversation/?protocol=data"
data = {
"messages": [
{
"id": "msg-1",
"role": "user",
"parts": [{"text": "Hello", "type": "text"}],
"content": "Hello",
"createdAt": "2025-07-03T15:22:17.105Z",
}
]
}
api_client.force_login(conversation.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
# Wait for the streaming content to be fully received
response_content = b"".join(response.streaming_content).decode("utf-8")
response_content = replace_uuids_with_placeholder(response_content)
assert response_content == (
'0:"Hello"\n'
'0:" there"\n'
'f:{"messageId":"<mocked_uuid>"}\n'
'd:{"finishReason":"stop","usage":{"promptTokens":0,"completionTokens":0}}\n'
)
last_request_payload = json.loads(respx.calls.last.request.content)
system_message = last_request_payload["messages"][0]
assert system_message["role"] == "system"
assert system_message["content"] == (
"You are a helpful test assistant :)\n\nToday is Friday 25/07/2025.\n\nAnswer in english."
)
assert mock_openai_stream.called
@@ -2,9 +2,11 @@
import pytest
from rest_framework import status
from rest_framework.exceptions import ErrorDetail
from core.factories import UserFactory
from chat.factories import ChatProjectFactory
from chat.models import ChatConversation
pytestmark = pytest.mark.django_db
@@ -52,6 +54,60 @@ def test_create_conversation_other_owner(api_client):
assert conversation.title == "New Conversation"
def test_create_conversation_with_project(api_client):
"""Test creating a conversation attached to a project."""
project = ChatProjectFactory()
url = "/api/v1.0/chats/"
data = {
"title": "New Conversation",
"project": str(project.pk),
}
api_client.force_login(project.owner)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert str(response.data["project"]) == str(project.pk)
conversation = ChatConversation.objects.get(id=response.data["id"])
assert conversation.project == project
def test_create_conversation_with_other_user_project_fails(api_client):
"""Test that creating a conversation with another user's project is rejected."""
user = UserFactory()
other_project = ChatProjectFactory() # owned by another user
url = "/api/v1.0/chats/"
data = {
"title": "New Conversation",
"project": str(other_project.pk),
}
api_client.force_login(user)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.data == {
"project": [
ErrorDetail(
string="The project must belong to the current user.",
code="invalid",
)
]
}
def test_create_conversation_without_project(api_client):
"""Test creating a conversation without a project."""
user = UserFactory()
url = "/api/v1.0/chats/"
data = {"title": "New Conversation"}
api_client.force_login(user)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["project"] is None
def test_create_conversation_anonymous(api_client):
"""Test creating a conversation as an anonymous user returns a 401 error."""
url = "/api/v1.0/chats/"
+143 -1
View File
@@ -5,7 +5,7 @@ from rest_framework import status
from core.factories import UserFactory
from chat.factories import ChatConversationFactory
from chat.factories import ChatConversationFactory, ChatProjectFactory
from chat.models import ChatConversation
pytestmark = pytest.mark.django_db
@@ -79,3 +79,145 @@ def test_ordering_conversations(api_client):
assert response.status_code == status.HTTP_200_OK
assert response.data["results"][0]["id"] == str(conv1.id)
assert response.data["results"][1]["id"] == str(conv2.id)
def test_list_conversations_no_project_filter_returns_all(api_client):
"""Test that without project filter, all conversations are returned."""
user = UserFactory()
project = ChatProjectFactory(owner=user)
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
conv_no_project = ChatConversationFactory(owner=user, title="No project")
api_client.force_login(user)
response = api_client.get("/api/v1.0/chats/")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 2
result_ids = {r["id"] for r in response.data["results"]}
assert result_ids == {str(conv_in_project.pk), str(conv_no_project.pk)}
def test_filter_conversations_by_project(api_client):
"""Test filtering conversations by a specific project."""
user = UserFactory()
project = ChatProjectFactory(owner=user)
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
ChatConversationFactory(owner=user, title="No project")
api_client.force_login(user)
response = api_client.get(f"/api/v1.0/chats/?project={project.pk}")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["id"] == str(conv_in_project.pk)
def test_filter_conversations_by_project_invalid_uuid(api_client):
"""Test that an invalid UUID for project filter returns empty results."""
user = UserFactory()
ChatConversationFactory(owner=user, title="Some chat")
api_client.force_login(user)
response = api_client.get("/api/v1.0/chats/?project=notauuid")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
def test_filter_conversations_by_project_none(api_client):
"""Test filtering conversations not linked to any project."""
user = UserFactory()
project = ChatProjectFactory(owner=user)
ChatConversationFactory(owner=user, project=project, title="In project")
conv_no_project = ChatConversationFactory(owner=user, title="No project")
api_client.force_login(user)
response = api_client.get("/api/v1.0/chats/?project=none")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["id"] == str(conv_no_project.pk)
def test_filter_conversations_by_project_any(api_client):
"""Test filtering conversations linked to any project."""
user = UserFactory()
project = ChatProjectFactory(owner=user)
conv_in_project = ChatConversationFactory(owner=user, project=project, title="In project")
ChatConversationFactory(owner=user, title="No project")
api_client.force_login(user)
response = api_client.get("/api/v1.0/chats/?project=any")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["id"] == str(conv_in_project.pk)
def test_filter_conversations_by_title_and_project(api_client):
"""Test filtering conversations by both title and project."""
user = UserFactory()
project = ChatProjectFactory(owner=user)
conv_match = ChatConversationFactory(owner=user, project=project, title="Design review")
ChatConversationFactory(owner=user, project=project, title="Budget plan")
ChatConversationFactory(owner=user, title="Design ideas")
api_client.force_login(user)
response = api_client.get(f"/api/v1.0/chats/?title=Design&project={project.pk}")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["id"] == str(conv_match.pk)
def test_search_by_title_returns_nested_project_info(api_client):
"""Test that searching by title returns nested project info (id, title, icon)."""
user = UserFactory()
project = ChatProjectFactory(owner=user, title="My Project", icon="folder")
conv = ChatConversationFactory(owner=user, project=project, title="Hello world")
api_client.force_login(user)
response = api_client.get("/api/v1.0/chats/?title=Hello")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
result = response.data["results"][0]
assert result["id"] == str(conv.pk)
assert result["project"] == {
"id": str(project.pk),
"title": "My Project",
"icon": "folder",
}
assert "messages" not in result
def test_search_by_title_returns_null_project_when_none(api_client):
"""Test that searching by title for a conversation without a project returns null."""
user = UserFactory()
conv = ChatConversationFactory(owner=user, title="Standalone chat")
api_client.force_login(user)
response = api_client.get("/api/v1.0/chats/?title=Standalone")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
result = response.data["results"][0]
assert result["id"] == str(conv.pk)
assert result["project"] is None
def test_list_without_title_filter_does_not_nest_project(api_client):
"""Test that listing without title filter returns project as a UUID, not nested."""
user = UserFactory()
project = ChatProjectFactory(owner=user)
ChatConversationFactory(owner=user, project=project, title="Some chat")
api_client.force_login(user)
response = api_client.get("/api/v1.0/chats/")
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
result = response.data["results"][0]
# project should be a flat UUID, not a nested dict
assert not isinstance(result["project"], dict)
assert str(result["project"]) == str(project.pk)
@@ -0,0 +1,87 @@
"""Unit tests for partially updating chat conversations in the chat API view."""
import pytest
from rest_framework import status
from core.factories import UserFactory
from chat.factories import ChatConversationFactory, ChatProjectFactory
from chat.models import ChatConversation
pytestmark = pytest.mark.django_db
def test_partial_update_conversation_title(api_client):
"""Test partially updating a chat conversation title as the owner."""
chat_conversation = ChatConversationFactory(title="Original Title")
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
data = {"title": "Updated Title"}
api_client.force_login(chat_conversation.owner)
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["title"] == "Updated Title"
conversation = ChatConversation.objects.get(id=chat_conversation.pk)
assert conversation.title == "Updated Title"
assert conversation.title_set_by_user_at
def test_partial_update_conversation_anonymous(api_client):
"""Test partially updating a conversation as an anonymous user returns a 401 error."""
chat_conversation = ChatConversationFactory()
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
data = {"title": "Updated Title"}
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_partial_update_conversation_project_fails(api_client):
"""Test that partially updating a conversation's project is rejected."""
conversation = ChatConversationFactory()
project = ChatProjectFactory(owner=conversation.owner)
url = f"/api/v1.0/chats/{conversation.pk}/"
data = {"project": str(project.pk)}
api_client.force_login(conversation.owner)
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "project" in response.data
conversation.refresh_from_db()
assert conversation.project is None
def test_partial_update_conversation_remove_project_fails(api_client):
"""Test that trying to remove a conversation from a project fails."""
project = ChatProjectFactory()
conversation = ChatConversationFactory(owner=project.owner, project=project)
url = f"/api/v1.0/chats/{conversation.pk}/"
data = {"project": None}
api_client.force_login(conversation.owner)
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "project" in response.data
conversation.refresh_from_db()
assert conversation.project == project
def test_partial_update_other_user_conversation_fails(api_client):
"""Test that partially updating another user's conversation returns a 404 error."""
chat_conversation = ChatConversationFactory()
other_user = UserFactory()
url = f"/api/v1.0/chats/{chat_conversation.pk}/"
data = {"title": "Updated By Other User"}
api_client.force_login(other_user)
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -6,7 +6,7 @@ from rest_framework.exceptions import ErrorDetail
from core.factories import UserFactory
from chat.factories import ChatConversationFactory
from chat.factories import ChatConversationFactory, ChatProjectFactory
from chat.models import ChatConversation
pytestmark = pytest.mark.django_db
@@ -68,6 +68,42 @@ def test_update_conversation_anonymous(api_client):
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_update_conversation_project_fails(api_client):
"""Test that updating a conversation's project is rejected."""
conversation = ChatConversationFactory()
project = ChatProjectFactory(owner=conversation.owner)
url = f"/api/v1.0/chats/{conversation.pk}/"
data = {"title": "Updated Title", "project": str(project.pk)}
api_client.force_login(conversation.owner)
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "project" in response.data
conversation.refresh_from_db()
assert conversation.project is None
def test_update_conversation_remove_project_fails(api_client):
"""Test that trying to remove a conversation from a project fails."""
project = ChatProjectFactory()
conversation = ChatConversationFactory(owner=project.owner, project=project)
url = f"/api/v1.0/chats/{conversation.pk}/"
data = {"title": "Updated Title", "project": None}
api_client.force_login(conversation.owner)
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "project" in response.data
conversation.refresh_from_db()
assert conversation.project == project
def test_update_other_user_conversation_fails(api_client):
"""Test that updating another user's conversation returns a 404 error."""
chat_conversation = ChatConversationFactory()
@@ -0,0 +1,109 @@
"""Unit tests for creating projects."""
import pytest
from rest_framework import status
from core.factories import UserFactory
from chat.models import ChatProject, ChatProjectColor, ChatProjectIcon
pytestmark = pytest.mark.django_db
def test_create_project(api_client):
"""Test creating a new project as an authenticated user."""
user = UserFactory(sub="testuser", email="test@example.com")
url = "/api/v1.0/projects/"
data = {
"title": "New Project",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
api_client.force_login(user)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["title"] == "New Project"
# Verify in database
project = ChatProject.objects.get(id=response.data["id"])
assert project.owner == user
assert project.title == "New Project"
assert project.icon == ChatProjectIcon.FOLDER
assert project.color == ChatProjectColor.COLOR_1
def test_create_project_other_owner(api_client):
"""Test that a user cannot assign another user as the owner of a project."""
other_user = UserFactory()
user = UserFactory()
url = "/api/v1.0/projects/"
data = {
"title": "New Project",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
"owner": str(other_user.pk), # Attempt to set another user as owner
}
api_client.force_login(user)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
# Verify in database
project = ChatProject.objects.get(id=response.data["id"])
assert project.owner == user
assert project.title == "New Project"
def test_create_project_with_llm_instructions(api_client):
"""Test creating a project with custom llm instructions."""
user = UserFactory()
url = "/api/v1.0/projects/"
data = {
"title": "New Project",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
"llm_instructions": "Always answer in French.",
}
api_client.force_login(user)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
assert response.data["llm_instructions"] == "Always answer in French."
project = ChatProject.objects.get(id=response.data["id"])
assert project.llm_instructions == "Always answer in French."
@pytest.mark.parametrize("color", ChatProjectColor)
def test_create_project_with_each_color(api_client, color):
"""Test creating a project with each available color."""
user = UserFactory()
url = "/api/v1.0/projects/"
data = {
"title": "Color Project",
"icon": ChatProjectIcon.FOLDER,
"color": color,
}
api_client.force_login(user)
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_201_CREATED
project = ChatProject.objects.get(id=response.data["id"])
assert project.color == color
def test_create_project_anonymous(api_client):
"""Test creating a project as an anonymous user returns a 401 error."""
url = "/api/v1.0/projects/"
data = {
"title": "New Project",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
response = api_client.post(url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -0,0 +1,72 @@
"""Unit tests for project deletion in the chat API view."""
import pytest
from rest_framework import status
from core.factories import UserFactory
from chat.factories import ChatProjectFactory
from chat.models import ChatConversation, ChatProject
pytestmark = pytest.mark.django_db
def test_delete_project(api_client):
"""Test deleting a project as the owner."""
project = ChatProjectFactory()
api_client.force_login(project.owner)
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
assert response.status_code == status.HTTP_204_NO_CONTENT
# Verify deletion in database
assert not ChatProject.objects.filter(id=project.pk).exists()
def test_delete_project_deletes_related_conversations(api_client):
"""Test that deleting a project also deletes its conversations."""
project = ChatProjectFactory(number_of_conversations=2)
conversation_pks = list(project.conversations.values_list("pk", flat=True))
api_client.force_login(project.owner)
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not ChatProject.objects.filter(id=project.pk).exists()
assert not ChatConversation.objects.filter(pk__in=conversation_pks).exists()
def test_delete_project_does_not_affect_other_conversations(api_client):
"""Test that deleting a project does not delete conversations from other projects."""
project = ChatProjectFactory(number_of_conversations=1)
other_project = ChatProjectFactory(owner=project.owner, number_of_conversations=1)
other_conversation = other_project.conversations.get()
api_client.force_login(project.owner)
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
assert response.status_code == status.HTTP_204_NO_CONTENT
assert ChatConversation.objects.filter(pk=other_conversation.pk).exists()
def test_delete_other_user_project_fails(api_client):
"""Test that deleting another user's project returns a 404 error."""
project = ChatProjectFactory()
other_user = UserFactory()
api_client.force_login(other_user)
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify project still exists
assert ChatProject.objects.filter(id=project.pk).exists()
def test_delete_project_user_anonymous(api_client):
"""Test deleting a project as an anonymous user returns a 401 error."""
project = ChatProjectFactory()
response = api_client.delete(f"/api/v1.0/projects/{project.pk}/")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# Verify project still exists
assert ChatProject.objects.filter(id=project.pk).exists()
@@ -0,0 +1,177 @@
"""Unit tests for listing projects in the chat API view."""
import pytest
from freezegun import freeze_time
from rest_framework import status
from core.factories import UserFactory
from chat.factories import ChatConversationFactory, ChatProjectFactory
pytestmark = pytest.mark.django_db
def test_list_projects(api_client, django_assert_num_queries):
"""Test retrieving the list of projects for an authenticated user."""
project = ChatProjectFactory()
url = "/api/v1.0/projects/"
api_client.force_login(project.owner)
with django_assert_num_queries(4): # user, project count, project list, conversations
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
results = response.data["results"]
assert len(results) == 1
assert results[0]["id"] == str(project.pk)
assert results[0]["title"] == project.title
assert results[0]["conversations"] == []
def test_filter_projects_by_title(api_client):
"""Test filtering projects by title substring."""
user = UserFactory(sub="testuser", email="test@example.com")
ChatProjectFactory(owner=user, title="Test Project")
ChatProjectFactory(owner=user, title="Other Project")
url = "/api/v1.0/projects/?title=Test"
api_client.force_login(user)
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 1
assert response.data["results"][0]["title"] == "Test Project"
def test_list_projects_with_conversations(api_client, django_assert_num_queries):
"""Test retrieving projects with associated conversations ordered by -created_at."""
project = ChatProjectFactory()
with freeze_time("2026-01-01"):
conversation_1 = ChatConversationFactory(
project=project, owner=project.owner, title="My conversation 1"
)
with freeze_time("2026-01-02"):
conversation_2 = ChatConversationFactory(
project=project, owner=project.owner, title="My conversation 2"
)
url = "/api/v1.0/projects/"
api_client.force_login(project.owner)
with django_assert_num_queries(4): # user, project count, project list, conversations
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
results = response.data["results"]
assert len(results) == 1
assert results[0]["id"] == str(project.pk)
assert results[0]["title"] == project.title
assert results[0]["conversations"] == [
{
"id": str(conversation_2.id),
"title": conversation_2.title,
},
{
"id": str(conversation_1.id),
"title": conversation_1.title,
},
]
def test_list_projects_no_n_plus_one(api_client, django_assert_num_queries):
"""Test that query count stays constant regardless of project/conversation count."""
user = UserFactory()
for _ in range(3):
ChatProjectFactory(owner=user, number_of_conversations=2)
url = "/api/v1.0/projects/"
api_client.force_login(user)
with django_assert_num_queries(4): # user, project count, project list, conversations
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 3
def test_list_projects_ordered_by_title(api_client):
"""Test that projects are returned in alphabetical order by title."""
user = UserFactory()
ChatProjectFactory(owner=user, title="Zeta")
ChatProjectFactory(owner=user, title="Alpha")
ChatProjectFactory(owner=user, title="Mu")
api_client.force_login(user)
response = api_client.get("/api/v1.0/projects/")
assert response.status_code == status.HTTP_200_OK
titles = [r["title"] for r in response.data["results"]]
assert titles == ["Alpha", "Mu", "Zeta"]
@pytest.mark.parametrize(
"ordering,expected_titles",
[
("created_at", ["First", "Second", "Third"]),
("-created_at", ["Third", "Second", "First"]),
("title", ["First", "Second", "Third"]),
("-title", ["Third", "Second", "First"]),
],
)
def test_list_projects_ordering(api_client, ordering, expected_titles):
"""Test ordering projects by the allowed ordering fields."""
user = UserFactory()
with freeze_time("2026-01-01"):
ChatProjectFactory(owner=user, title="First")
with freeze_time("2026-01-02"):
ChatProjectFactory(owner=user, title="Second")
with freeze_time("2026-01-03"):
ChatProjectFactory(owner=user, title="Third")
api_client.force_login(user)
response = api_client.get(f"/api/v1.0/projects/?ordering={ordering}")
assert response.status_code == status.HTTP_200_OK
titles = [r["title"] for r in response.data["results"]]
assert titles == expected_titles
def test_list_projects_ordering_by_updated_at(api_client):
"""Test ordering projects by updated_at."""
user = UserFactory()
with freeze_time("2026-01-01"):
project_a = ChatProjectFactory(owner=user, title="A")
ChatProjectFactory(owner=user, title="B")
with freeze_time("2026-01-02"):
project_a.title = "A updated"
project_a.save()
api_client.force_login(user)
response = api_client.get("/api/v1.0/projects/?ordering=updated_at")
assert response.status_code == status.HTTP_200_OK
titles = [r["title"] for r in response.data["results"]]
assert titles == ["B", "A updated"]
def test_list_projects_empty(api_client):
"""Test retrieving the list of projects for an authenticated user."""
user = UserFactory()
url = "/api/v1.0/projects/"
api_client.force_login(user)
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data["results"]) == 0
def test_list_projects_anonymous(api_client):
"""Test listing projects as an anonymous user returns a 401 error."""
url = "/api/v1.0/projects/"
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -0,0 +1,66 @@
"""Unit tests for partial update of projects in the chat API view."""
import pytest
from rest_framework import status
from core.factories import UserFactory
from chat.factories import ChatProjectFactory
from chat.models import ChatProjectColor, ChatProjectIcon
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize(
"field,value",
[
("title", "Updated Title"),
("icon", ChatProjectIcon.STAR),
("color", ChatProjectColor.COLOR_3),
("llm_instructions", "Always answer in French."),
],
)
def test_partial_update_project(api_client, field, value):
"""Test updating a project field as the owner."""
project = ChatProjectFactory()
url = f"/api/v1.0/projects/{project.pk}/"
data = {field: value}
api_client.force_login(project.owner)
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data[field] == value
# Verify in database
project.refresh_from_db()
assert getattr(project, field) == value
def test_partial_update_other_user_project_fails(api_client):
"""Test that updating another user's project returns a 404 error."""
project = ChatProjectFactory()
other_user = UserFactory()
url = f"/api/v1.0/projects/{project.pk}/"
data = {
"title": "Updated By Other User",
}
api_client.force_login(other_user)
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_partial_update_project_anonymous(api_client):
"""Test updating a project as an anonymous user returns a 401 error."""
project = ChatProjectFactory()
url = f"/api/v1.0/projects/{project.pk}/"
data = {
"title": "Updated Title",
}
response = api_client.patch(url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -0,0 +1,45 @@
"""Unit tests for retrieving projects."""
import pytest
from rest_framework import status
from core.factories import UserFactory
from chat.factories import ChatProjectFactory
pytestmark = pytest.mark.django_db
def test_retrieve_project(api_client):
"""Test retrieving a project as the owner."""
project = ChatProjectFactory()
url = f"/api/v1.0/projects/{project.pk}/"
api_client.force_login(project.owner)
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert response.data["id"] == str(project.pk)
assert response.data["title"] == project.title
def test_retrieve_other_user_project_fails(api_client):
"""Test that retrieving another user's project returns a 404 error."""
project = ChatProjectFactory()
other_user = UserFactory()
url = f"/api/v1.0/projects/{project.pk}/"
api_client.force_login(other_user)
response = api_client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_retrieve_project_anonymous(api_client):
"""Test retrieving a project as an anonymous user returns a 401 error."""
project = ChatProjectFactory()
url = f"/api/v1.0/projects/{project.pk}/"
response = api_client.get(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -0,0 +1,86 @@
"""Unit tests for updating projects in the chat API view."""
import pytest
from rest_framework import status
from core.factories import UserFactory
from chat.factories import ChatProjectFactory
from chat.models import ChatProjectColor, ChatProjectIcon
pytestmark = pytest.mark.django_db
def test_update_project(api_client):
"""Test updating a project as the owner."""
project = ChatProjectFactory()
url = f"/api/v1.0/projects/{project.pk}/"
data = {
"title": "Updated Title",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
api_client.force_login(project.owner)
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["title"] == "Updated Title"
# Verify in database
project.refresh_from_db()
assert project.title == "Updated Title"
def test_update_project_llm_instructions(api_client):
"""Test updating a project's LLM instructions via PUT."""
project = ChatProjectFactory(llm_instructions="Old instructions")
url = f"/api/v1.0/projects/{project.pk}/"
data = {
"title": project.title,
"icon": project.icon,
"color": project.color,
"llm_instructions": "New instructions",
}
api_client.force_login(project.owner)
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data["llm_instructions"] == "New instructions"
project.refresh_from_db()
assert project.llm_instructions == "New instructions"
def test_update_other_user_project_fails(api_client):
"""Test that updating another user's project returns a 404 error."""
project = ChatProjectFactory()
other_user = UserFactory()
url = f"/api/v1.0/projects/{project.pk}/"
data = {
"title": "Updated By Other User",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
api_client.force_login(other_user)
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_update_project_anonymous(api_client):
"""Test updating a project as an anonymous user returns a 401 error."""
project = ChatProjectFactory()
url = f"/api/v1.0/projects/{project.pk}/"
data = {
"title": "Updated Title",
"icon": ChatProjectIcon.FOLDER,
"color": ChatProjectColor.COLOR_1,
}
response = api_client.put(url, data, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
+126 -9
View File
@@ -2,17 +2,19 @@
import logging
import os
from uuid import uuid4
from uuid import UUID, uuid4
from django.conf import settings
from django.core.cache import cache
from django.core.files.storage import default_storage
from django.db.models import Prefetch
from django.http import Http404, StreamingHttpResponse
from django.utils.decorators import method_decorator
import langfuse
import magic
import posthog
from drf_spectacular.utils import extend_schema
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from rest_framework import decorators, filters, mixins, permissions, status, viewsets
@@ -50,8 +52,8 @@ def conditional_refresh_oidc_token(func):
return func
class ChatConversationFilter(filters.BaseFilterBackend):
"""Filter conversation."""
class TitleSearchFilter(filters.BaseFilterBackend):
"""Filter conversation by title (accent-insensitive)."""
def filter_queryset(self, request, queryset, view):
"""Filter conversation by title."""
@@ -59,6 +61,58 @@ class ChatConversationFilter(filters.BaseFilterBackend):
queryset = queryset.filter(title__unaccent__icontains=remove_accents(title))
return queryset
def get_schema_operation_parameters(self, view):
"""Return the schema for the ``title`` query parameter (drf-spectacular)."""
return [
{
"name": "title",
"required": False,
"in": "query",
"description": "Search conversations by title (accent-insensitive). "
"When provided, the response uses a search-specific serializer "
"with nested project info.",
"schema": {"type": "string"},
},
]
class ProjectFilter(filters.BaseFilterBackend):
"""Filter conversations by project.
Accepts a `project` query parameter:
- a UUID: conversations belonging to that specific project
- "none": conversations not linked to any project
- "any": conversations linked to any project
"""
def filter_queryset(self, request, queryset, view):
"""Filter conversations by project."""
project_id = request.GET.get("project")
if project_id is None:
return queryset
if project_id == "none":
return queryset.filter(project__isnull=True)
if project_id == "any":
return queryset.filter(project__isnull=False)
try:
UUID(project_id)
except ValueError:
return queryset.none()
return queryset.filter(project_id=project_id)
def get_schema_operation_parameters(self, view):
"""Return the schema for the ``project`` query parameter (drf-spectacular)."""
return [
{
"name": "project",
"required": False,
"in": "query",
"description": "Filter by project. Pass a UUID for a specific project, "
'"none" for standalone conversations, or "any" for all project conversations.',
"schema": {"type": "string"},
},
]
class ChatAttachmentMixin(AttachmentMixin): # pylint: disable=abstract-method
"""Mixin to handle attachment authorization for chat conversations."""
@@ -121,18 +175,42 @@ class ChatViewSet( # pylint: disable=too-many-ancestors, abstract-method
]
serializer_class = serializers.ChatConversationSerializer
post_conversation_serializer_class = serializers.ChatConversationInputSerializer
filter_backends = [filters.OrderingFilter, ChatConversationFilter]
filter_backends = [filters.OrderingFilter, TitleSearchFilter, ProjectFilter]
ordering = ["-created_at"]
ordering_fields = ["created_at", "updated_at"]
queryset = models.ChatConversation.objects # defined to be used in AttachmentMixin
@extend_schema(
responses=serializers.ChatConversationSearchSerializer(many=True),
description=(
"When the `title` query parameter is provided, returns search results "
"with nested project info (id, title, icon) and no messages. "
"Without `title`, returns the default conversation list."
),
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
def get_serializer_class(self):
"""Return search serializer when filtering by title on list action."""
# Search results only include nested project info
if self.action == "list" and self.request.query_params.get("title"):
return serializers.ChatConversationSearchSerializer
return super().get_serializer_class()
def get_queryset(self):
"""Return the queryset for the chat conversations."""
return (
self.queryset.filter(owner=self.request.user)
if self.request.user.is_authenticated
else self.queryset.none()
)
if not self.request.user.is_authenticated:
return self.queryset.none()
qs = self.queryset.filter(owner=self.request.user)
# Search results use nested project info; post_conversation needs
# project.llm_instructions — prefetch to avoid extra queries
if self.request.query_params.get("title") or self.action == "post_conversation":
qs = qs.select_related("project")
return qs
def get_permissions(self):
"""Return the permissions for the viewset."""
@@ -624,3 +702,42 @@ class FileStreamView(APIView):
except Exception as exc:
logger.exception("Failed to serve file via temporary key: %s", temporary_key)
raise Http404("Failed to retrieve file") from exc
class ChatProjectViewSet(viewsets.ModelViewSet): # pylint: disable=too-many-ancestors
"""ViewSet for managing projects."""
pagination_class = Pagination
permission_classes = [
IsActivatedUser, # see activation_codes application
permissions.IsAuthenticated,
]
ordering = ["title"]
ordering_fields = ["title", "created_at", "updated_at"]
queryset = models.ChatProject.objects
serializer_class = serializers.ChatProjectSerializer
filter_backends = [filters.OrderingFilter, TitleSearchFilter]
def get_queryset(self):
"""Return the queryset for the projects."""
# Prefetch conversations ordered by most recent first
conversations_prefetch = Prefetch(
"conversations",
queryset=models.ChatConversation.objects.order_by("-created_at"),
)
return (
self.queryset.filter(owner=self.request.user).prefetch_related(conversations_prefetch)
if self.request.user.is_authenticated
else self.queryset.none()
)
def perform_destroy(self, instance):
"""Delete a project and its related conversations.
ChatConversation.project uses on_delete=SET_NULL (to avoid accidental
cascade), so we explicitly delete conversations here.
"""
instance.conversations.all().delete()
instance.delete()
+1
View File
@@ -368,6 +368,7 @@ class Base(BraveSettings, Configuration):
"django.contrib.staticfiles",
# OIDC third party
"mozilla_django_oidc",
"lasuite.malware_detection",
]
# Cache
+2
View File
@@ -12,6 +12,7 @@ from core.file_upload.enums import FileToLLMMode
from activation_codes import viewsets as activation_viewsets
from chat.views import (
ChatConversationAttachmentViewSet,
ChatProjectViewSet,
ChatViewSet,
FileStreamView,
LLMConfigurationView,
@@ -21,6 +22,7 @@ from chat.views import (
router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("chats", ChatViewSet, basename="chats")
router.register("projects", ChatProjectViewSet, basename="projects")
router.register("activation", activation_viewsets.ActivationViewSet, basename="activation")
conversation_router = DefaultRouter()
+80 -88
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-02-09 13:05\n"
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
"PO-Revision-Date: 2026-03-11 15:23\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -17,328 +17,320 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
#: activation_codes/admin.py:55
msgid "Configuration"
msgstr ""
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
#: activation_codes/admin.py:66
msgid "Usage details"
msgstr ""
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr ""
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
#: activation_codes/admin.py:109
msgid "Usage"
msgstr ""
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
#: activation_codes/admin.py:117
msgid "Description"
msgstr ""
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
#: activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr ""
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
#: activation_codes/admin.py:135
msgid "Name"
msgstr ""
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr ""
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
#: activation_codes/admin.py:137
msgid "Date"
msgstr ""
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
#: activation_codes/admin.py:161
msgid "Users who used this code"
msgstr ""
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
#: activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr ""
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
#: activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#: activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr ""
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
#: activation_codes/admin.py:291
msgid "Has used activation code"
msgstr ""
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
#: activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#: activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr ""
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr ""
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
#: activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#: activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr ""
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
#: activation_codes/models.py:178
msgid "activation code"
msgstr ""
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
#: activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr ""
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
#: activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
#: activation_codes/models.py:52
msgid "maximum uses"
msgstr ""
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
#: activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr ""
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
#: activation_codes/models.py:58
msgid "current uses"
msgstr ""
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
#: activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr ""
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
#: activation_codes/models.py:65 core/models.py:154
msgid "active"
msgstr ""
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
#: activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr ""
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
#: activation_codes/models.py:71
msgid "expires at"
msgstr ""
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
#: activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr ""
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
#: activation_codes/models.py:78
msgid "description"
msgstr ""
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
#: activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr ""
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
#: activation_codes/models.py:86
msgid "activation codes"
msgstr ""
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
#: activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr ""
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
#: activation_codes/models.py:136
msgid "You have already activated your account"
msgstr ""
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
#: core/models.py:182
msgid "user"
msgstr ""
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
#: activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr ""
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
#: activation_codes/models.py:179
msgid "The activation code that was used"
msgstr ""
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr ""
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
#: activation_codes/models.py:187
msgid "user activations"
msgstr ""
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
#: activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr ""
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
#: activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr ""
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
#: activation_codes/models.py:220
msgid "user registration request"
msgstr ""
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
#: activation_codes/models.py:221
msgid "user registration requests"
msgstr ""
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr ""
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
#: activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr ""
#: build/lib/chat/apps.py:12 chat/apps.py:12
#: chat/apps.py:12
msgid "chat application"
msgstr ""
#: build/lib/core/admin.py:26 core/admin.py:26
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:40 core/admin.py:40
#: core/admin.py:40
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:52 core/admin.py:52
#: core/admin.py:52
msgid "Important dates"
msgstr ""
#: build/lib/core/models.py:39 core/models.py:39
#: core/models.py:39
msgid "id"
msgstr "id"
#: build/lib/core/models.py:40 core/models.py:40
#: core/models.py:40
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:46 core/models.py:46
#: core/models.py:46
msgid "created on"
msgstr ""
#: build/lib/core/models.py:47 core/models.py:47
#: core/models.py:47
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:52 core/models.py:52
#: core/models.py:52
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:53 core/models.py:53
#: core/models.py:53
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:86 core/models.py:86
#: core/models.py:89
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:99 core/models.py:99
#: core/models.py:102
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:105 core/models.py:105
#: core/models.py:108
msgid "sub"
msgstr ""
#: build/lib/core/models.py:107 core/models.py:107
#: core/models.py:110
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:116 core/models.py:116
#: core/models.py:119
msgid "full name"
msgstr ""
#: build/lib/core/models.py:117 core/models.py:117
#: core/models.py:120
msgid "short name"
msgstr ""
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:122
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:123 core/models.py:123
#: core/models.py:126
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:129 core/models.py:129
#: core/models.py:132
msgid "language"
msgstr ""
#: build/lib/core/models.py:130 core/models.py:130
#: core/models.py:133
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:138 core/models.py:138
#: core/models.py:141
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:141 core/models.py:141
#: core/models.py:144
msgid "device"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: core/models.py:146
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:146 core/models.py:146
#: core/models.py:149
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:148 core/models.py:148
#: core/models.py:151
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:157
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:164
msgid "allow conversation analytics"
msgstr ""
#: build/lib/core/models.py:163 core/models.py:163
#: core/models.py:166
msgid "Whether the user allows to use their conversations for analytics."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: core/models.py:170
msgid "allow smart web search"
msgstr ""
#: core/models.py:172
msgid "Whether the user allows to use smart web search features."
msgstr ""
#: core/models.py:183
msgid "users"
msgstr ""
+80 -88
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-02-09 13:05\n"
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
"PO-Revision-Date: 2026-03-11 15:23\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -17,328 +17,320 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
#: activation_codes/admin.py:55
msgid "Configuration"
msgstr ""
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
#: activation_codes/admin.py:66
msgid "Usage details"
msgstr ""
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr ""
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
#: activation_codes/admin.py:109
msgid "Usage"
msgstr ""
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
#: activation_codes/admin.py:117
msgid "Description"
msgstr ""
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
#: activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr ""
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
#: activation_codes/admin.py:135
msgid "Name"
msgstr ""
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr ""
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
#: activation_codes/admin.py:137
msgid "Date"
msgstr ""
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
#: activation_codes/admin.py:161
msgid "Users who used this code"
msgstr ""
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
#: activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr ""
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
#: activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr ""
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#: activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr ""
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr ""
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
#: activation_codes/admin.py:291
msgid "Has used activation code"
msgstr ""
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
#: activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#: activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr ""
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr ""
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
#: activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr ""
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#: activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr ""
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
#: activation_codes/models.py:178
msgid "activation code"
msgstr ""
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
#: activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr ""
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
#: activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr ""
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
#: activation_codes/models.py:52
msgid "maximum uses"
msgstr ""
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
#: activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr ""
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
#: activation_codes/models.py:58
msgid "current uses"
msgstr ""
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
#: activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr ""
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
#: activation_codes/models.py:65 core/models.py:154
msgid "active"
msgstr ""
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
#: activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr ""
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
#: activation_codes/models.py:71
msgid "expires at"
msgstr ""
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
#: activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr ""
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
#: activation_codes/models.py:78
msgid "description"
msgstr ""
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
#: activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr ""
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
#: activation_codes/models.py:86
msgid "activation codes"
msgstr ""
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
#: activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr ""
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
#: activation_codes/models.py:136
msgid "You have already activated your account"
msgstr ""
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
#: core/models.py:182
msgid "user"
msgstr ""
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
#: activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr ""
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
#: activation_codes/models.py:179
msgid "The activation code that was used"
msgstr ""
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr ""
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
#: activation_codes/models.py:187
msgid "user activations"
msgstr ""
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
#: activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr ""
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
#: activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr ""
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
#: activation_codes/models.py:220
msgid "user registration request"
msgstr ""
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
#: activation_codes/models.py:221
msgid "user registration requests"
msgstr ""
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr ""
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
#: activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr ""
#: build/lib/chat/apps.py:12 chat/apps.py:12
#: chat/apps.py:12
msgid "chat application"
msgstr ""
#: build/lib/core/admin.py:26 core/admin.py:26
#: core/admin.py:26
msgid "Personal info"
msgstr ""
#: build/lib/core/admin.py:40 core/admin.py:40
#: core/admin.py:40
msgid "Permissions"
msgstr ""
#: build/lib/core/admin.py:52 core/admin.py:52
#: core/admin.py:52
msgid "Important dates"
msgstr ""
#: build/lib/core/models.py:39 core/models.py:39
#: core/models.py:39
msgid "id"
msgstr ""
#: build/lib/core/models.py:40 core/models.py:40
#: core/models.py:40
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:46 core/models.py:46
#: core/models.py:46
msgid "created on"
msgstr ""
#: build/lib/core/models.py:47 core/models.py:47
#: core/models.py:47
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:52 core/models.py:52
#: core/models.py:52
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:53 core/models.py:53
#: core/models.py:53
msgid "date and time at which a record was last updated"
msgstr ""
#: build/lib/core/models.py:86 core/models.py:86
#: core/models.py:89
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr ""
#: build/lib/core/models.py:99 core/models.py:99
#: core/models.py:102
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr ""
#: build/lib/core/models.py:105 core/models.py:105
#: core/models.py:108
msgid "sub"
msgstr ""
#: build/lib/core/models.py:107 core/models.py:107
#: core/models.py:110
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:116 core/models.py:116
#: core/models.py:119
msgid "full name"
msgstr ""
#: build/lib/core/models.py:117 core/models.py:117
#: core/models.py:120
msgid "short name"
msgstr ""
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:122
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:123 core/models.py:123
#: core/models.py:126
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:129 core/models.py:129
#: core/models.py:132
msgid "language"
msgstr ""
#: build/lib/core/models.py:130 core/models.py:130
#: core/models.py:133
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:138 core/models.py:138
#: core/models.py:141
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:141 core/models.py:141
#: core/models.py:144
msgid "device"
msgstr ""
#: build/lib/core/models.py:143 core/models.py:143
#: core/models.py:146
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:146 core/models.py:146
#: core/models.py:149
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:148 core/models.py:148
#: core/models.py:151
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:157
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr ""
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:164
msgid "allow conversation analytics"
msgstr ""
#: build/lib/core/models.py:163 core/models.py:163
#: core/models.py:166
msgid "Whether the user allows to use their conversations for analytics."
msgstr ""
#: build/lib/core/models.py:174 core/models.py:174
#: core/models.py:170
msgid "allow smart web search"
msgstr ""
#: core/models.py:172
msgid "Whether the user allows to use smart web search features."
msgstr ""
#: core/models.py:183
msgid "users"
msgstr ""
+80 -88
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-02-09 13:05\n"
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
"PO-Revision-Date: 2026-03-11 15:23\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -17,328 +17,320 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
#: activation_codes/admin.py:55
msgid "Configuration"
msgstr "Configuration"
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
#: activation_codes/admin.py:66
msgid "Usage details"
msgstr "Détails d'utilisation"
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr "Horodatages"
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
#: activation_codes/admin.py:109
msgid "Usage"
msgstr "Utilisation"
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
#: activation_codes/admin.py:117
msgid "Description"
msgstr "Description"
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
#: activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr "Aucun utilisateur n'a encore utilisé ce code"
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
#: activation_codes/admin.py:135
msgid "Name"
msgstr "Nom"
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr "E-mail"
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
#: activation_codes/admin.py:137
msgid "Date"
msgstr "Date"
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
#: activation_codes/admin.py:161
msgid "Users who used this code"
msgstr "Utilisateurs qui ont utilisé ce code"
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
#: activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr "Recalculer les utilisations actuelles à partir des activations liées"
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
#: activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr "Tous les codes d'activation sélectionnés ont déjà un nombre correct d'utilisations."
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#: activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr "Utilisation recalculée avec succès pour %(count)d code(s) d'activation."
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr "Utilisateur"
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
#: activation_codes/admin.py:291
msgid "Has used activation code"
msgstr "A utilisé le code d'activation"
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
#: activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Ajouter les utilisateurs sélectionnés à la liste d'attente Brevo"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#: activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "%(count)d utilisateur(s) ajoutés à la liste d'attente Brevo."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "Aucune adresse e-mail valide trouvée dans les inscriptions sélectionnées."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
#: activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Supprimer les utilisateurs sélectionnés de la liste d'attente Brevo"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#: activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "Suppression de %(count)d utilisateur(s) de la liste d'attente Brevo."
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
#: activation_codes/models.py:178
msgid "activation code"
msgstr "code d'activation"
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
#: activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr "Le code d'activation que les utilisateurs entreront"
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
#: activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr "Le code doit être alphanumérique et ne contenir ni espaces ni caractères spéciaux"
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
#: activation_codes/models.py:52
msgid "maximum uses"
msgstr "utilisations maximales"
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
#: activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr "Nombre maximum d'utilisation de ce code. 0 signifie illimité."
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
#: activation_codes/models.py:58
msgid "current uses"
msgstr "utilisations actuelles"
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
#: activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr "Nombre de fois où ce code a été utilisé"
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
#: activation_codes/models.py:65 core/models.py:154
msgid "active"
msgstr "actif"
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
#: activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr "Si ce code peut encore être utilisé"
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
#: activation_codes/models.py:71
msgid "expires at"
msgstr "expiration"
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
#: activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr "Date et heure d'expiration de ce code"
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
#: activation_codes/models.py:78
msgid "description"
msgstr "description"
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
#: activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr "Description interne ou notes à propos de ce code"
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
#: activation_codes/models.py:86
msgid "activation codes"
msgstr "codes d'activation"
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
#: activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr "Ce code d'activation n'est plus valide"
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
#: activation_codes/models.py:136
msgid "You have already activated your account"
msgstr "Vous avez déjà activé votre compte"
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
#: core/models.py:182
msgid "user"
msgstr "utilisateur"
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
#: activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr "L'utilisateur qui a utilisé le code d'activation"
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
#: activation_codes/models.py:179
msgid "The activation code that was used"
msgstr "Le code d'activation qui a été utilisé"
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr "activation d'utilisateur"
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
#: activation_codes/models.py:187
msgid "user activations"
msgstr "activations d'utilisateurs"
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
#: activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr "L'utilisateur qui a fait la demande d'enregistrement"
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
#: activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr "Enregistrer si l'utilisateur a reçu un code d'activation et l'a utilisé"
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
#: activation_codes/models.py:220
msgid "user registration request"
msgstr "demande d'inscription d'utilisateur"
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
#: activation_codes/models.py:221
msgid "user registration requests"
msgstr "demandes d'inscription d'utilisateur"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr "Le code d'activation à valider"
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
#: activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr "Votre compte a été activé avec succès"
#: build/lib/chat/apps.py:12 chat/apps.py:12
#: chat/apps.py:12
msgid "chat application"
msgstr "application de chat"
#: build/lib/core/admin.py:26 core/admin.py:26
#: core/admin.py:26
msgid "Personal info"
msgstr "Informations personnelles"
#: build/lib/core/admin.py:40 core/admin.py:40
#: core/admin.py:40
msgid "Permissions"
msgstr "Permissions"
#: build/lib/core/admin.py:52 core/admin.py:52
#: core/admin.py:52
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/models.py:39 core/models.py:39
#: core/models.py:39
msgid "id"
msgstr "id"
#: build/lib/core/models.py:40 core/models.py:40
#: core/models.py:40
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement en tant que UUID"
#: build/lib/core/models.py:46 core/models.py:46
#: core/models.py:46
msgid "created on"
msgstr "créé le"
#: build/lib/core/models.py:47 core/models.py:47
#: core/models.py:47
msgid "date and time at which a record was created"
msgstr "date et heure de création de l'enregistrement"
#: build/lib/core/models.py:52 core/models.py:52
#: core/models.py:52
msgid "updated on"
msgstr "mis à jour le"
#: build/lib/core/models.py:53 core/models.py:53
#: core/models.py:53
msgid "date and time at which a record was last updated"
msgstr "date et heure de la dernière mise à jour de l'enregistrement"
#: build/lib/core/models.py:86 core/models.py:86
#: core/models.py:89
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Nous n'avons pas pu trouver un utilisateur avec ce sous-groupe mais l'e-mail est déjà associé à un utilisateur enregistré."
#: build/lib/core/models.py:99 core/models.py:99
#: core/models.py:102
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Saisissez un 'sub' valide. Cette valeur ne peut contenir que des lettres, des chiffres et les caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:105 core/models.py:105
#: core/models.py:108
msgid "sub"
msgstr "sub"
#: build/lib/core/models.py:107 core/models.py:107
#: core/models.py:110
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./+/-/_/: uniquement."
#: build/lib/core/models.py:116 core/models.py:116
#: core/models.py:119
msgid "full name"
msgstr "nom complet"
#: build/lib/core/models.py:117 core/models.py:117
#: core/models.py:120
msgid "short name"
msgstr "nom court"
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:122
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: build/lib/core/models.py:123 core/models.py:123
#: core/models.py:126
msgid "admin email address"
msgstr "adresse e-mail de l'administrateur"
#: build/lib/core/models.py:129 core/models.py:129
#: core/models.py:132
msgid "language"
msgstr "langue"
#: build/lib/core/models.py:130 core/models.py:130
#: core/models.py:133
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur veut voir l'interface."
#: build/lib/core/models.py:138 core/models.py:138
#: core/models.py:141
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: build/lib/core/models.py:141 core/models.py:141
#: core/models.py:144
msgid "device"
msgstr "appareil"
#: build/lib/core/models.py:143 core/models.py:143
#: core/models.py:146
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: build/lib/core/models.py:146 core/models.py:146
#: core/models.py:149
msgid "staff status"
msgstr "statut d'équipe"
#: build/lib/core/models.py:148 core/models.py:148
#: core/models.py:151
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:157
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Si cet utilisateur doit être traité comme actif. Désélectionnez ceci au lieu de supprimer des comptes."
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:164
msgid "allow conversation analytics"
msgstr "autoriser les analyses de conversation"
#: build/lib/core/models.py:163 core/models.py:163
#: core/models.py:166
msgid "Whether the user allows to use their conversations for analytics."
msgstr "Si l'utilisateur autorise l'utilisation de ses conversations pour des analyses."
#: build/lib/core/models.py:174 core/models.py:174
#: core/models.py:170
msgid "allow smart web search"
msgstr "autoriser la recherche intelligente sur le web"
#: core/models.py:172
msgid "Whether the user allows to use smart web search features."
msgstr "Si l'utilisateur autorise l'utilisation de fonctions de recherche intelligente sur le Web."
#: core/models.py:183
msgid "users"
msgstr "utilisateurs"
+80 -88
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-02-09 13:05\n"
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
"PO-Revision-Date: 2026-03-11 15:23\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -17,328 +17,320 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
#: activation_codes/admin.py:55
msgid "Configuration"
msgstr "Configuratie"
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
#: activation_codes/admin.py:66
msgid "Usage details"
msgstr "Gebruiksdetails"
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr "Tijdstempels"
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
#: activation_codes/admin.py:109
msgid "Usage"
msgstr "Gebruik"
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
#: activation_codes/admin.py:117
msgid "Description"
msgstr "Beschrijving"
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
#: activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr "Er zijn nog geen gebruikers die deze code hebben gebruikt"
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
#: activation_codes/admin.py:135
msgid "Name"
msgstr "Naam"
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr "E-mail"
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
#: activation_codes/admin.py:137
msgid "Date"
msgstr "Datum"
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
#: activation_codes/admin.py:161
msgid "Users who used this code"
msgstr "Gebruikers die deze code hebben gebruikt"
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
#: activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr "Herbereken het huidige gebruik van gerelateerde activeringen"
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
#: activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr "Alle geselecteerde activeringscodes hebben al het juiste gebruiksaantal."
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#: activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr "Het gebruik van %(count)d activeringscode(s) is opnieuw berekend."
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr "Gebruiker"
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
#: activation_codes/admin.py:291
msgid "Has used activation code"
msgstr "Heeft activeringscode gebruikt"
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
#: activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Voeg geselecteerde gebruikers toe aan de Brevo-wachtlijst"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#: activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "%(count)d gebruiker(s) toegevoegd aan de Brevo-wachtlijst."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "Er is geen geldig e-mailadres gevonden in de geselecteerde registraties."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
#: activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Geselecteerde gebruikers van de Brevo-wachtlijst verwijderen"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#: activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "%(count)d gebruiker(s) verwijderd van de Brevo-wachtlijst."
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
#: activation_codes/models.py:178
msgid "activation code"
msgstr "activeringscode"
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
#: activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr "De activeringscode die gebruikers invoeren"
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
#: activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr "De code moet alfanumeriek zijn en mag geen spaties of speciale tekens bevatten"
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
#: activation_codes/models.py:52
msgid "maximum uses"
msgstr "maximaal gebruik"
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
#: activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr "Maximaal aantal keren dat deze code kan worden gebruikt. 0 betekent onbeperkt."
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
#: activation_codes/models.py:58
msgid "current uses"
msgstr "huidig gebruik"
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
#: activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr "Aantal keren dat deze code is gebruikt"
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
#: activation_codes/models.py:65 core/models.py:154
msgid "active"
msgstr "actief"
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
#: activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr "Of deze code nog gebruikt kan worden"
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
#: activation_codes/models.py:71
msgid "expires at"
msgstr "vervalt op"
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
#: activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr "Datum en tijd waarop deze code verloopt"
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
#: activation_codes/models.py:78
msgid "description"
msgstr "beschrijving"
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
#: activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr "Interne beschrijving of notities over deze code"
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
#: activation_codes/models.py:86
msgid "activation codes"
msgstr "activeringscodes"
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
#: activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr "Deze activeringscode is niet meer geldig"
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
#: activation_codes/models.py:136
msgid "You have already activated your account"
msgstr "Je hebt je account al geactiveerd"
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
#: core/models.py:182
msgid "user"
msgstr "gebruiker"
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
#: activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr "De gebruiker die de activeringscode heeft gebruikt"
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
#: activation_codes/models.py:179
msgid "The activation code that was used"
msgstr "De activeringscode die is gebruikt"
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr "gebruikers activering"
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
#: activation_codes/models.py:187
msgid "user activations"
msgstr "gebruikersactivaties"
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
#: activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr "De gebruiker die het registratieverzoek heeft gedaan"
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
#: activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr "Opslaan of de gebruiker een activeringscode heeft ontvangen en deze heeft gebruikt"
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
#: activation_codes/models.py:220
msgid "user registration request"
msgstr "gebruikersregistratieverzoek"
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
#: activation_codes/models.py:221
msgid "user registration requests"
msgstr "gebruikersregistratieverzoeken"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr "De activeringscode om te valideren"
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
#: activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr "Uw account is succesvol geactiveerd"
#: build/lib/chat/apps.py:12 chat/apps.py:12
#: chat/apps.py:12
msgid "chat application"
msgstr "chatapplicatie"
#: build/lib/core/admin.py:26 core/admin.py:26
#: core/admin.py:26
msgid "Personal info"
msgstr "Persoonlijke gegevens"
#: build/lib/core/admin.py:40 core/admin.py:40
#: core/admin.py:40
msgid "Permissions"
msgstr "Machtigingen"
#: build/lib/core/admin.py:52 core/admin.py:52
#: core/admin.py:52
msgid "Important dates"
msgstr "Belangrijke data"
#: build/lib/core/models.py:39 core/models.py:39
#: core/models.py:39
msgid "id"
msgstr "id"
#: build/lib/core/models.py:40 core/models.py:40
#: core/models.py:40
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: build/lib/core/models.py:46 core/models.py:46
#: core/models.py:46
msgid "created on"
msgstr "gemaakt op"
#: build/lib/core/models.py:47 core/models.py:47
#: core/models.py:47
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record is aangemaakt"
#: build/lib/core/models.py:52 core/models.py:52
#: core/models.py:52
msgid "updated on"
msgstr "bijgewerkt op"
#: build/lib/core/models.py:53 core/models.py:53
#: core/models.py:53
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst is bijgewerkt"
#: build/lib/core/models.py:86 core/models.py:86
#: core/models.py:89
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "We konden geen gebruiker met dit e-mailadres vinden, maar het e-mailadres is al gekoppeld aan een geregistreerde gebruiker."
#: build/lib/core/models.py:99 core/models.py:99
#: core/models.py:102
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Voer een geldig subsubtype in. Deze waarde mag alleen letters, cijfers en @/./+/-/_/:-tekens bevatten."
#: build/lib/core/models.py:105 core/models.py:105
#: core/models.py:108
msgid "sub"
msgstr "id"
#: build/lib/core/models.py:107 core/models.py:107
#: core/models.py:110
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Verplicht. Maximaal 255 tekens. Alleen letters, cijfers en @/./+/-/_/: tekens."
#: build/lib/core/models.py:116 core/models.py:116
#: core/models.py:119
msgid "full name"
msgstr "volledige naam"
#: build/lib/core/models.py:117 core/models.py:117
#: core/models.py:120
msgid "short name"
msgstr "korte naam"
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:122
msgid "identity email address"
msgstr "identiteits e-mailadres"
#: build/lib/core/models.py:123 core/models.py:123
#: core/models.py:126
msgid "admin email address"
msgstr "beheerders e-mailadres"
#: build/lib/core/models.py:129 core/models.py:129
#: core/models.py:132
msgid "language"
msgstr "taal"
#: build/lib/core/models.py:130 core/models.py:130
#: core/models.py:133
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: build/lib/core/models.py:138 core/models.py:138
#: core/models.py:141
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker de tijden wil zien."
#: build/lib/core/models.py:141 core/models.py:141
#: core/models.py:144
msgid "device"
msgstr "apparaat"
#: build/lib/core/models.py:143 core/models.py:143
#: core/models.py:146
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat of een echte gebruiker is."
#: build/lib/core/models.py:146 core/models.py:146
#: core/models.py:149
msgid "staff status"
msgstr "personeelsstatus"
#: build/lib/core/models.py:148 core/models.py:148
#: core/models.py:151
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:157
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Of deze gebruiker als actief moet worden beschouwd. Deselecteer dit in plaats van accounts te verwijderen."
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:164
msgid "allow conversation analytics"
msgstr "conversatieanalyse toestaan"
#: build/lib/core/models.py:163 core/models.py:163
#: core/models.py:166
msgid "Whether the user allows to use their conversations for analytics."
msgstr "Of de gebruiker toestaat dat zijn/haar gesprekken voor analyses worden gebruikt."
#: build/lib/core/models.py:174 core/models.py:174
#: core/models.py:170
msgid "allow smart web search"
msgstr "smart web zoeken toestaan"
#: core/models.py:172
msgid "Whether the user allows to use smart web search features."
msgstr "Of de gebruiker toestemming geeft om smart web zoekfuncties te gebruiken."
#: core/models.py:183
msgid "users"
msgstr "gebruikers"
+80 -88
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-02-09 13:05\n"
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
"PO-Revision-Date: 2026-03-11 15:23\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -17,328 +17,320 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
#: activation_codes/admin.py:55
msgid "Configuration"
msgstr "Настройки"
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
#: activation_codes/admin.py:66
msgid "Usage details"
msgstr "Сведения об использовании"
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr "Временные метки"
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
#: activation_codes/admin.py:109
msgid "Usage"
msgstr "Использование"
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
#: activation_codes/admin.py:117
msgid "Description"
msgstr "Описание"
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
#: activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr "Пока нет пользователей, использовавших этот код"
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
#: activation_codes/admin.py:135
msgid "Name"
msgstr "Имя"
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr "Эл. почта"
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
#: activation_codes/admin.py:137
msgid "Date"
msgstr "Дата"
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
#: activation_codes/admin.py:161
msgid "Users who used this code"
msgstr "Пользователи, использующие этот код"
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
#: activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr "Обновить данные использования связанных активаций"
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
#: activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr "Все выбранные коды активации уже имеют правильное количество использований."
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#: activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr "Количество использованных кодов активации (%(count)d) успешно пересчитано."
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr "Пользователь"
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
#: activation_codes/admin.py:291
msgid "Has used activation code"
msgstr "Использует код активации"
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
#: activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Добавить выбранных пользователей в список ожидания Brevo"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#: activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "В список ожидания Brevo добавлено пользователей: %(count)d."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "В выбранных регистрациях не найден действительный адрес электронной почты."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
#: activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Удалить выбранных пользователей из списка ожидания Brevo"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#: activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "Из списка ожидания Brevo удалено пользователей: %(count)d."
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
#: activation_codes/models.py:178
msgid "activation code"
msgstr "код активации"
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
#: activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr "Код активации, который будут вводить пользователи"
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
#: activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr "Код должен быть буквенно-цифровым и не содержать пробелов или специальных символов"
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
#: activation_codes/models.py:52
msgid "maximum uses"
msgstr "максимум использований"
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
#: activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr "Сколько раз можно использовать этот код. 0 означает неограниченно."
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
#: activation_codes/models.py:58
msgid "current uses"
msgstr "использовано"
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
#: activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr "Сколько раз этот код был использован"
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
#: activation_codes/models.py:65 core/models.py:154
msgid "active"
msgstr "активный"
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
#: activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr "Можно ли ещё использовать этот код"
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
#: activation_codes/models.py:71
msgid "expires at"
msgstr "действителен до"
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
#: activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr "Дата и время окончания действия этого кода"
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
#: activation_codes/models.py:78
msgid "description"
msgstr "описание"
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
#: activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr "Внутреннее описание или примечания об этом коде"
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
#: activation_codes/models.py:86
msgid "activation codes"
msgstr "коды активации"
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
#: activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr "Этот код активации больше не действителен"
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
#: activation_codes/models.py:136
msgid "You have already activated your account"
msgstr "Вы уже активировали свою учётную запись"
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
#: core/models.py:182
msgid "user"
msgstr "пользователь"
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
#: activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr "Пользователь, использовавший код активации"
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
#: activation_codes/models.py:179
msgid "The activation code that was used"
msgstr "Использованный код активации"
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr "активация пользователя"
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
#: activation_codes/models.py:187
msgid "user activations"
msgstr "активации пользователя"
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
#: activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr "Пользователь, который сделал запрос на регистрацию"
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
#: activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr "Сохранить, если пользователь получил код активации и использовал его"
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
#: activation_codes/models.py:220
msgid "user registration request"
msgstr "запрос на регистрацию пользователя"
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
#: activation_codes/models.py:221
msgid "user registration requests"
msgstr "запросы на регистрацию пользователя"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr "Код активации для проверки"
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
#: activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr "Ваша учётная запись успешно активирована"
#: build/lib/chat/apps.py:12 chat/apps.py:12
#: chat/apps.py:12
msgid "chat application"
msgstr "приложение чата"
#: build/lib/core/admin.py:26 core/admin.py:26
#: core/admin.py:26
msgid "Personal info"
msgstr "Личные данные"
#: build/lib/core/admin.py:40 core/admin.py:40
#: core/admin.py:40
msgid "Permissions"
msgstr "Разрешения"
#: build/lib/core/admin.py:52 core/admin.py:52
#: core/admin.py:52
msgid "Important dates"
msgstr "Важные даты"
#: build/lib/core/models.py:39 core/models.py:39
#: core/models.py:39
msgid "id"
msgstr "id"
#: build/lib/core/models.py:40 core/models.py:40
#: core/models.py:40
msgid "primary key for the record as UUID"
msgstr "первичный ключ для записи как UUID"
#: build/lib/core/models.py:46 core/models.py:46
#: core/models.py:46
msgid "created on"
msgstr "создано"
#: build/lib/core/models.py:47 core/models.py:47
#: core/models.py:47
msgid "date and time at which a record was created"
msgstr "дата и время создания записи"
#: build/lib/core/models.py:52 core/models.py:52
#: core/models.py:52
msgid "updated on"
msgstr "обновлено"
#: build/lib/core/models.py:53 core/models.py:53
#: core/models.py:53
msgid "date and time at which a record was last updated"
msgstr "дата и время последнего обновления записи"
#: build/lib/core/models.py:86 core/models.py:86
#: core/models.py:89
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Мы не смогли найти пользователя с этими данными, но этот адрес уже связан с зарегистрированным пользователем."
#: build/lib/core/models.py:99 core/models.py:99
#: core/models.py:102
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Введите правильный префикс. Он может содержать только буквы, цифры и символы @/./+/-/_/."
#: build/lib/core/models.py:105 core/models.py:105
#: core/models.py:108
msgid "sub"
msgstr "префикс"
#: build/lib/core/models.py:107 core/models.py:107
#: core/models.py:110
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Обязательно. 255 символов или меньше. Только буквы, цифры и @/./+/-/_/: /."
#: build/lib/core/models.py:116 core/models.py:116
#: core/models.py:119
msgid "full name"
msgstr "полное имя"
#: build/lib/core/models.py:117 core/models.py:117
#: core/models.py:120
msgid "short name"
msgstr "короткое имя"
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:122
msgid "identity email address"
msgstr "личный адрес электронной почты"
#: build/lib/core/models.py:123 core/models.py:123
#: core/models.py:126
msgid "admin email address"
msgstr "e-mail администратора"
#: build/lib/core/models.py:129 core/models.py:129
#: core/models.py:132
msgid "language"
msgstr "язык"
#: build/lib/core/models.py:130 core/models.py:130
#: core/models.py:133
msgid "The language in which the user wants to see the interface."
msgstr "Язык, на котором пользователь хочет видеть интерфейс."
#: build/lib/core/models.py:138 core/models.py:138
#: core/models.py:141
msgid "The timezone in which the user wants to see times."
msgstr "Часовой пояс, в котором пользователь хочет видеть время."
#: build/lib/core/models.py:141 core/models.py:141
#: core/models.py:144
msgid "device"
msgstr "устройство"
#: build/lib/core/models.py:143 core/models.py:143
#: core/models.py:146
msgid "Whether the user is a device or a real user."
msgstr "Пользователь является устройством или человеком."
#: build/lib/core/models.py:146 core/models.py:146
#: core/models.py:149
msgid "staff status"
msgstr "статус сотрудника"
#: build/lib/core/models.py:148 core/models.py:148
#: core/models.py:151
msgid "Whether the user can log into this admin site."
msgstr "Может ли пользователь войти на этот административный сайт."
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:157
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Должен ли пользователь рассматриваться как активный. Альтернатива удалению учётных записей."
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:164
msgid "allow conversation analytics"
msgstr "разрешить аналитику для беседы"
#: build/lib/core/models.py:163 core/models.py:163
#: core/models.py:166
msgid "Whether the user allows to use their conversations for analytics."
msgstr "Разрешает ли пользователь использовать свои беседы для аналитики."
#: build/lib/core/models.py:174 core/models.py:174
#: core/models.py:170
msgid "allow smart web search"
msgstr "разрешить умный поиск в Интернете"
#: core/models.py:172
msgid "Whether the user allows to use smart web search features."
msgstr "Разрешает ли пользователь использовать умный поиск в Интернете."
#: core/models.py:183
msgid "users"
msgstr "пользователи"
+80 -88
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: la-suite-conversations\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-20 21:48+0000\n"
"PO-Revision-Date: 2026-02-09 13:05\n"
"POT-Creation-Date: 2026-02-26 21:42+0000\n"
"PO-Revision-Date: 2026-03-11 15:23\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -17,328 +17,320 @@ msgstr ""
"X-Crowdin-File: backend-conversations.pot\n"
"X-Crowdin-File-ID: 26\n"
#: activation_codes/admin.py:55 build/lib/activation_codes/admin.py:55
#: activation_codes/admin.py:55
msgid "Configuration"
msgstr "Налаштування"
#: activation_codes/admin.py:66 build/lib/activation_codes/admin.py:66
#: activation_codes/admin.py:66
msgid "Usage details"
msgstr "Відомості про використання"
#: activation_codes/admin.py:70 activation_codes/admin.py:226
#: build/lib/activation_codes/admin.py:70
#: build/lib/activation_codes/admin.py:226
msgid "Timestamps"
msgstr "Відмітки часу"
#: activation_codes/admin.py:109 build/lib/activation_codes/admin.py:109
#: activation_codes/admin.py:109
msgid "Usage"
msgstr "Використання"
#: activation_codes/admin.py:117 build/lib/activation_codes/admin.py:117
#: activation_codes/admin.py:117
msgid "Description"
msgstr "Опис"
#: activation_codes/admin.py:124 build/lib/activation_codes/admin.py:124
#: activation_codes/admin.py:124
msgid "No users have used this code yet"
msgstr "Користувачі ще не використовували цей код"
#: activation_codes/admin.py:135 build/lib/activation_codes/admin.py:135
#: activation_codes/admin.py:135
msgid "Name"
msgstr "Ім’я"
#: activation_codes/admin.py:136 activation_codes/admin.py:246
#: build/lib/activation_codes/admin.py:136
#: build/lib/activation_codes/admin.py:246
msgid "Email"
msgstr "Ел. пошта"
#: activation_codes/admin.py:137 build/lib/activation_codes/admin.py:137
#: activation_codes/admin.py:137
msgid "Date"
msgstr "Дата"
#: activation_codes/admin.py:161 build/lib/activation_codes/admin.py:161
#: activation_codes/admin.py:161
msgid "Users who used this code"
msgstr "Користувачі, що використовували цей код"
#: activation_codes/admin.py:163 build/lib/activation_codes/admin.py:163
#: activation_codes/admin.py:163
msgid "Recompute current uses from related activations"
msgstr "Перерахувати поточні використання пов'язаних ресурсів"
#: activation_codes/admin.py:177 build/lib/activation_codes/admin.py:177
#: activation_codes/admin.py:177
msgid "All selected activation codes already have correct usage counts."
msgstr "Усі обрані коди активації вже мають коректні лічильники використання."
#: activation_codes/admin.py:182 build/lib/activation_codes/admin.py:182
#: activation_codes/admin.py:182
#, python-format
msgid "Successfully recomputed usage counts for %(count)d activation code(s)."
msgstr "Успішно переобчислено використання коду активації %(count)d."
#: activation_codes/admin.py:240 activation_codes/admin.py:284
#: build/lib/activation_codes/admin.py:240
#: build/lib/activation_codes/admin.py:284
msgid "User"
msgstr "Користувач"
#: activation_codes/admin.py:291 build/lib/activation_codes/admin.py:291
#: activation_codes/admin.py:291
msgid "Has used activation code"
msgstr "Використано код активації"
#: activation_codes/admin.py:293 build/lib/activation_codes/admin.py:293
#: activation_codes/admin.py:293
msgid "Add selected users to Brevo waiting list"
msgstr "Додати обраних користувачів до списку очікування Brevo"
#: activation_codes/admin.py:314 build/lib/activation_codes/admin.py:314
#: activation_codes/admin.py:314
#, python-format
msgid "Added %(count)d user(s) to Brevo waiting list."
msgstr "До списку очікування Brevo додано користувачів: %(count)d."
#: activation_codes/admin.py:319 activation_codes/admin.py:347
#: build/lib/activation_codes/admin.py:319
#: build/lib/activation_codes/admin.py:347
msgid "No valid email address found in selected registrations."
msgstr "Серед обраних реєстрацій не знайдено дійсної адреси електронної пошти."
#: activation_codes/admin.py:323 build/lib/activation_codes/admin.py:323
#: activation_codes/admin.py:323
msgid "Remove selected users from Brevo waiting list"
msgstr "Видалити обраних користувачів зі списку очікування Brevo"
#: activation_codes/admin.py:342 build/lib/activation_codes/admin.py:342
#: activation_codes/admin.py:342
#, python-format
msgid "Removed %(count)d user(s) from Brevo waiting list."
msgstr "Зі списку очікування Brevo видалено користувачів: %(count)d"
#: activation_codes/models.py:38 activation_codes/models.py:85
#: activation_codes/models.py:178 build/lib/activation_codes/models.py:38
#: build/lib/activation_codes/models.py:85
#: build/lib/activation_codes/models.py:178
#: activation_codes/models.py:178
msgid "activation code"
msgstr "код активації"
#: activation_codes/models.py:39 build/lib/activation_codes/models.py:39
#: activation_codes/models.py:39
msgid "The activation code that users will enter"
msgstr "Код активації, що буде введений користувачами"
#: activation_codes/models.py:46 build/lib/activation_codes/models.py:46
#: activation_codes/models.py:46
msgid "Code must be alphanumeric and contain no spaces or special characters"
msgstr "Код має бути буквено-цифровим, без пробілів або спеціальних символів"
#: activation_codes/models.py:52 build/lib/activation_codes/models.py:52
#: activation_codes/models.py:52
msgid "maximum uses"
msgstr "максимум використань"
#: activation_codes/models.py:53 build/lib/activation_codes/models.py:53
#: activation_codes/models.py:53
msgid "Maximum number of times this code can be used. 0 means unlimited."
msgstr "Максимальна кількість разів використання для цього коду. 0 - необмежена."
#: activation_codes/models.py:58 build/lib/activation_codes/models.py:58
#: activation_codes/models.py:58
msgid "current uses"
msgstr "використано"
#: activation_codes/models.py:59 build/lib/activation_codes/models.py:59
#: activation_codes/models.py:59
msgid "Number of times this code has been used"
msgstr "Кількість разів використання цього коду"
#: activation_codes/models.py:65 build/lib/activation_codes/models.py:65
#: build/lib/core/models.py:151 core/models.py:151
#: activation_codes/models.py:65 core/models.py:154
msgid "active"
msgstr "активний"
#: activation_codes/models.py:66 build/lib/activation_codes/models.py:66
#: activation_codes/models.py:66
msgid "Whether this code can still be used"
msgstr "Чи цей код все ще може бути використаний"
#: activation_codes/models.py:71 build/lib/activation_codes/models.py:71
#: activation_codes/models.py:71
msgid "expires at"
msgstr "дійсний до"
#: activation_codes/models.py:72 build/lib/activation_codes/models.py:72
#: activation_codes/models.py:72
msgid "Date and time when this code expires"
msgstr "Дата та час, коли закінчується дія цього коду"
#: activation_codes/models.py:78 build/lib/activation_codes/models.py:78
#: activation_codes/models.py:78
msgid "description"
msgstr "опис"
#: activation_codes/models.py:79 build/lib/activation_codes/models.py:79
#: activation_codes/models.py:79
msgid "Internal description or notes about this code"
msgstr "Внутрішній опис або нотатки про цей код"
#: activation_codes/models.py:86 build/lib/activation_codes/models.py:86
#: activation_codes/models.py:86
msgid "activation codes"
msgstr "коди активації"
#: activation_codes/models.py:128 build/lib/activation_codes/models.py:128
#: activation_codes/models.py:128
msgid "This activation code is no longer valid"
msgstr "Цей код активації вже не дійсний"
#: activation_codes/models.py:136 build/lib/activation_codes/models.py:136
#: activation_codes/models.py:136
msgid "You have already activated your account"
msgstr "Ви вже активували свій обліковий запис"
#: activation_codes/models.py:170 activation_codes/models.py:202
#: build/lib/activation_codes/models.py:170
#: build/lib/activation_codes/models.py:202 build/lib/core/models.py:173
#: core/models.py:173
#: core/models.py:182
msgid "user"
msgstr "користувач"
#: activation_codes/models.py:171 build/lib/activation_codes/models.py:171
#: activation_codes/models.py:171
msgid "The user who used the activation code"
msgstr "Користувач, який користувався кодом активації"
#: activation_codes/models.py:179 build/lib/activation_codes/models.py:179
#: activation_codes/models.py:179
msgid "The activation code that was used"
msgstr "Використаний код активації"
#: activation_codes/models.py:186 activation_codes/models.py:210
#: build/lib/activation_codes/models.py:186
#: build/lib/activation_codes/models.py:210
msgid "user activation"
msgstr "активація користувача"
#: activation_codes/models.py:187 build/lib/activation_codes/models.py:187
#: activation_codes/models.py:187
msgid "user activations"
msgstr "активації користувача"
#: activation_codes/models.py:203 build/lib/activation_codes/models.py:203
#: activation_codes/models.py:203
msgid "The user who made the registration request"
msgstr "Користувач, що зробив запит на реєстрацію"
#: activation_codes/models.py:211 build/lib/activation_codes/models.py:211
#: activation_codes/models.py:211
msgid "Store if the user received an activation code and used it"
msgstr "Зберегти, якщо користувач отримав код активації та використав його"
#: activation_codes/models.py:220 build/lib/activation_codes/models.py:220
#: activation_codes/models.py:220
msgid "user registration request"
msgstr "запит на реєстрацію користувача"
#: activation_codes/models.py:221 build/lib/activation_codes/models.py:221
#: activation_codes/models.py:221
msgid "user registration requests"
msgstr "запити на реєстрацію користувачів"
#: activation_codes/serializers.py:14
#: build/lib/activation_codes/serializers.py:14
msgid "The activation code to validate"
msgstr "Код активації для перевірки"
#: activation_codes/viewsets.py:107 build/lib/activation_codes/viewsets.py:107
#: activation_codes/viewsets.py:107
msgid "Your account has been successfully activated"
msgstr "Ваш обліковий запис успішно активовано"
#: build/lib/chat/apps.py:12 chat/apps.py:12
#: chat/apps.py:12
msgid "chat application"
msgstr "чат-застосунок"
#: build/lib/core/admin.py:26 core/admin.py:26
#: core/admin.py:26
msgid "Personal info"
msgstr "Особисті дані"
#: build/lib/core/admin.py:40 core/admin.py:40
#: core/admin.py:40
msgid "Permissions"
msgstr "Дозволи"
#: build/lib/core/admin.py:52 core/admin.py:52
#: core/admin.py:52
msgid "Important dates"
msgstr "Важливі дати"
#: build/lib/core/models.py:39 core/models.py:39
#: core/models.py:39
msgid "id"
msgstr "id"
#: build/lib/core/models.py:40 core/models.py:40
#: core/models.py:40
msgid "primary key for the record as UUID"
msgstr "первинний ключ для запису як UUID"
#: build/lib/core/models.py:46 core/models.py:46
#: core/models.py:46
msgid "created on"
msgstr "створено"
#: build/lib/core/models.py:47 core/models.py:47
#: core/models.py:47
msgid "date and time at which a record was created"
msgstr "дата і час, коли запис було створено"
#: build/lib/core/models.py:52 core/models.py:52
#: core/models.py:52
msgid "updated on"
msgstr "оновлено"
#: build/lib/core/models.py:53 core/models.py:53
#: core/models.py:53
msgid "date and time at which a record was last updated"
msgstr "дата і час, коли запис був востаннє оновлений"
#: build/lib/core/models.py:86 core/models.py:86
#: core/models.py:89
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
msgstr "Ми не змогли знайти користувача з цими даними, але адреса вже пов'язана з зареєстрованим користувачем."
#: build/lib/core/models.py:99 core/models.py:99
#: core/models.py:102
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
msgstr "Введіть правильний префікс. Це значення може містити лише літери, цифри та символи @/./+/-/_/."
#: build/lib/core/models.py:105 core/models.py:105
#: core/models.py:108
msgid "sub"
msgstr "префікс"
#: build/lib/core/models.py:107 core/models.py:107
#: core/models.py:110
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Обов'язково. 255 символів або менше. Лише літери, цифри та символи @/./+/-/_/."
#: build/lib/core/models.py:116 core/models.py:116
#: core/models.py:119
msgid "full name"
msgstr "повне ім'я"
#: build/lib/core/models.py:117 core/models.py:117
#: core/models.py:120
msgid "short name"
msgstr "коротке ім'я"
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:122
msgid "identity email address"
msgstr "адреса електронної пошти особи"
#: build/lib/core/models.py:123 core/models.py:123
#: core/models.py:126
msgid "admin email address"
msgstr "електронна адреса адміністратора"
#: build/lib/core/models.py:129 core/models.py:129
#: core/models.py:132
msgid "language"
msgstr "мова"
#: build/lib/core/models.py:130 core/models.py:130
#: core/models.py:133
msgid "The language in which the user wants to see the interface."
msgstr "Мова, якою користувач хоче бачити інтерфейс."
#: build/lib/core/models.py:138 core/models.py:138
#: core/models.py:141
msgid "The timezone in which the user wants to see times."
msgstr "Часовий пояс, в якому користувач хоче бачити час."
#: build/lib/core/models.py:141 core/models.py:141
#: core/models.py:144
msgid "device"
msgstr "пристрій"
#: build/lib/core/models.py:143 core/models.py:143
#: core/models.py:146
msgid "Whether the user is a device or a real user."
msgstr "Чи є користувач пристроєм чи реальним користувачем."
#: build/lib/core/models.py:146 core/models.py:146
#: core/models.py:149
msgid "staff status"
msgstr "статус співробітника"
#: build/lib/core/models.py:148 core/models.py:148
#: core/models.py:151
msgid "Whether the user can log into this admin site."
msgstr "Чи може користувач увійти на цей сайт адміністратора."
#: build/lib/core/models.py:154 core/models.py:154
#: core/models.py:157
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Чи слід ставитися до цього користувача як до активного. Зніміть вибір замість видалення облікового запису."
#: build/lib/core/models.py:161 core/models.py:161
#: core/models.py:164
msgid "allow conversation analytics"
msgstr "дозволити аналітику бесіди"
#: build/lib/core/models.py:163 core/models.py:163
#: core/models.py:166
msgid "Whether the user allows to use their conversations for analytics."
msgstr "Чи дозволяє користувач використовувати свої розмови для аналітики."
#: build/lib/core/models.py:174 core/models.py:174
#: core/models.py:170
msgid "allow smart web search"
msgstr "дозволити розумний пошук в Інтернеті"
#: core/models.py:172
msgid "Whether the user allows to use smart web search features."
msgstr "Чи дозволяє користувач використовувати розумні пошукові функції, пов'язані з Інтернетом."
#: core/models.py:183
msgid "users"
msgstr "користувачі"
+6 -5
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "conversations"
version = "0.0.13"
version = "0.0.14"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -33,13 +33,13 @@ dependencies = [
"django-cors-headers==4.9.0",
"django-countries==8.1.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.11",
"django==5.2.12",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2024.4.2",
@@ -58,7 +58,7 @@ dependencies = [
"pydantic==2.12.4",
"pydantic-ai-slim[openai,mistral,mcp,evals,logfire]==1.62.0",
"psycopg[binary]==3.2.12",
"PyJWT==2.10.1",
"PyJWT==2.12.0",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.5",
@@ -67,7 +67,7 @@ dependencies = [
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
"pypdf>=6.7.2",
"pypdf==6.9.1",
]
[project.urls]
@@ -115,6 +115,7 @@ required-environments = [
]
override-dependencies = [
"cryptography>=46.0.5", # CVE-2026-26007
"joserfc>=1.6.3", # CVE-2026-27932
"pillow>=12.1.1", #CVE-2026-25990
]
+22 -20
View File
@@ -13,6 +13,7 @@ required-markers = [
[manifest]
overrides = [
{ name = "cryptography", specifier = ">=46.0.5" },
{ name = "joserfc", specifier = ">=1.6.3" },
{ name = "pillow", specifier = ">=12.1.1" },
]
@@ -400,7 +401,7 @@ wheels = [
[[package]]
name = "conversations"
version = "0.0.13"
version = "0.0.14"
source = { editable = "." }
dependencies = [
{ name = "beautifulsoup4" },
@@ -480,13 +481,13 @@ requires-dist = [
{ name = "brotli", specifier = "==1.2.0" },
{ name = "deprecated" },
{ name = "dirty-equals", marker = "extra == 'dev'", specifier = "==0.10.0" },
{ name = "django", specifier = "==5.2.11" },
{ name = "django", specifier = "==5.2.12" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.1.0" },
{ name = "django-extensions", marker = "extra == 'dev'", specifier = "==4.1" },
{ name = "django-filter", specifier = "==25.2" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.18" },
{ name = "django-lasuite", extras = ["all"], specifier = "==0.0.25" },
{ name = "django-parler", specifier = "==2.3" },
{ name = "django-pydantic-field", specifier = "==0.5.4" },
{ name = "django-redis", specifier = "==6.0.0" },
@@ -516,11 +517,11 @@ requires-dist = [
{ name = "pydantic", specifier = "==2.12.4" },
{ name = "pydantic-ai-slim", extras = ["openai", "mistral", "mcp", "evals", "logfire"], specifier = "==1.62.0" },
{ name = "pyfakefs", marker = "extra == 'dev'", specifier = "==5.10.2" },
{ name = "pyjwt", specifier = "==2.10.1" },
{ name = "pyjwt", specifier = "==2.12.0" },
{ name = "pylint", marker = "extra == 'dev'", specifier = "==3.3.9" },
{ name = "pylint-django", marker = "extra == 'dev'", specifier = "==2.6.1" },
{ name = "pylint-pydantic", marker = "extra == 'dev'", specifier = "==0.4.1" },
{ name = "pypdf", specifier = ">=6.7.2" },
{ name = "pypdf", specifier = "==6.9.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.1" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.0.0" },
@@ -704,16 +705,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.11"
version = "5.2.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/f2/3e57ef696b95067e05ae206171e47a8e53b9c84eec56198671ef9eaa51a6/django-5.2.11.tar.gz", hash = "sha256:7f2d292ad8b9ee35e405d965fbbad293758b858c34bbf7f3df551aeeac6f02d3", size = 10885017, upload-time = "2026-02-03T13:52:50.554Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/a7/2b112ab430575bf3135b8304ac372248500d99c352f777485f53fdb9537e/django-5.2.11-py3-none-any.whl", hash = "sha256:e7130df33ada9ab5e5e929bc19346a20fe383f5454acb2cc004508f242ee92c0", size = 8291375, upload-time = "2026-02-03T13:52:42.47Z" },
{ url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" },
]
[[package]]
@@ -780,19 +781,20 @@ wheels = [
[[package]]
name = "django-lasuite"
version = "0.0.18"
version = "0.0.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "joserfc" },
{ name = "mozilla-django-oidc" },
{ name = "pyjwt" },
{ name = "requests" },
{ name = "requests-toolbelt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/af/e42d4307dc2505792b2e8e73c86f26fb24d2e674a95b148d0eb9618e193c/django_lasuite-0.0.18.tar.gz", hash = "sha256:be0c0b234025d9d2a76eb66189abb8bd8a581d5b1f5f66715751e6849c42fbe4", size = 29719, upload-time = "2025-11-12T09:02:01.474Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/bc/5826a3e5ada5dad1ff6af9a2e1eb598a0dc2cb9c4d2dbf011f1bbb310c9e/django_lasuite-0.0.25.tar.gz", hash = "sha256:ee44783942e6ead74a732f6d7280c5fca961b66581350ce01c0589c3e80684cf", size = 34819, upload-time = "2026-03-10T13:40:28.705Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/05/60f0114a3d7cf95eb1dd0d1c3234dd4c8b612264b4774733d2188b010036/django_lasuite-0.0.18-py3-none-any.whl", hash = "sha256:30754c0ff532174546355997c3cf143cb123a457c7e4ecbf0fc5404504aca0e9", size = 43422, upload-time = "2025-11-12T09:01:59.917Z" },
{ url = "https://files.pythonhosted.org/packages/61/2f/819ebbc9d3a4b8233cef1e9b0b607724e4acf527988393b04a4036b9c235/django_lasuite-0.0.25-py3-none-any.whl", hash = "sha256:4c10f625005cd41d05e8d34269f1a3b58fea8be5296528bd58596227f50d9884", size = 54034, upload-time = "2026-03-10T13:40:26.903Z" },
]
[package.optional-dependencies]
@@ -1314,14 +1316,14 @@ wheels = [
[[package]]
name = "joserfc"
version = "1.6.1"
version = "1.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c7/3d/82375487dcc2bcdf136a68e1a8543165feccbbc8833dfc451f87a5f83b81/joserfc-1.6.1.tar.gz", hash = "sha256:7759a14d732d93503317468c0dd258510c4f64b30759cf42e96016c97b38c4b7", size = 226277, upload-time = "2025-12-30T08:45:07.289Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/90/b8cc8635c4ce2e5e8104bf26ef147f6e599478f6329107283cdc53aae97f/joserfc-1.6.3.tar.gz", hash = "sha256:c00c2830db969b836cba197e830e738dd9dda0955f1794e55d3c636f17f5c9a6", size = 229090, upload-time = "2026-02-25T15:33:38.167Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/01/9674cc6d478406ae61d910cb16ca8b5699a8a9e6a2019987ebe5a5957d1d/joserfc-1.6.1-py3-none-any.whl", hash = "sha256:74d158c9d56be54c710cdcb2a0741372254b682ad2101a0f72e5bd0e925695f0", size = 70349, upload-time = "2025-12-30T08:45:05.573Z" },
{ url = "https://files.pythonhosted.org/packages/12/4f/124b3301067b752f44f292f0b9a74e837dd75ff863ee39500a082fc4c733/joserfc-1.6.3-py3-none-any.whl", hash = "sha256:6beab3635358cbc565cb94fb4c53d0557e6d10a15b933e2134939351590bda9a", size = 70465, upload-time = "2026-02-25T15:33:36.997Z" },
]
[[package]]
@@ -2300,11 +2302,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.10.1"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
{ url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" },
]
[package.optional-dependencies]
@@ -2370,11 +2372,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.7.2"
version = "6.9.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/b2/335465d6cff28a772ace8a58beb168f125c2e1d8f7a31527da180f4d89a1/pypdf-6.7.2.tar.gz", hash = "sha256:82a1a48de500ceea59a52a7d979f5095927ef802e4e4fac25ab862a73468acbb", size = 5302986, upload-time = "2026-02-22T11:33:30.776Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/fb/dc2e8cb006e80b0020ed20d8649106fe4274e82d8e756ad3e24ade19c0df/pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d", size = 5311551, upload-time = "2026-03-17T10:46:07.876Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/df/38b06d6e74646a4281856920a11efb431559bdeb643bf1e192bff5e29082/pypdf-6.7.2-py3-none-any.whl", hash = "sha256:331b63cd66f63138f152a700565b3e0cebdf4ec8bec3b7594b2522418782f1f3", size = 331245, upload-time = "2026-02-22T11:33:29.204Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f4/75543fa802b86e72f87e9395440fe1a89a6d149887e3e55745715c3352ac/pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f", size = 333661, upload-time = "2026-03-17T10:46:06.286Z" },
]
[[package]]
@@ -846,8 +846,6 @@ if (dsfrThemes.dark) {
dsfrThemes.dark.globals.colors['logo-2'] = '#E78087';
}
console.log(dsfrThemes);
const themes = {
themes: {
default: whiteLabelThemes.light,
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+9 -33
View File
@@ -1,7 +1,5 @@
const crypto = require('crypto');
const { InjectManifest } = require('workbox-webpack-plugin');
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
/** @type {import('next').NextConfig} */
@@ -12,44 +10,22 @@ const nextConfig = {
unoptimized: true,
},
compiler: {
// Enables the styled-components SWC transform
styledComponents: true,
},
generateBuildId: () => buildId,
env: {
NEXT_PUBLIC_BUILD_ID: buildId,
},
webpack(config, { isServer }) {
// Grab the existing rule that handles SVG imports
const fileLoaderRule = config.module.rules.find((rule) =>
rule.test?.test?.('.svg'),
);
config.module.rules.push(
// Reapply the existing rule, but only for svg imports ending in ?url
{
...fileLoaderRule,
test: /\.svg$/i,
resourceQuery: /url/, // *.svg?url
turbopack: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
// Convert all other *.svg imports to React components
{
test: /\.svg$/i,
issuer: fileLoaderRule.issuer,
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
use: ['@svgr/webpack'],
},
);
// Modify the file loader rule to ignore *.svg, since we have it handled now.
fileLoaderRule.exclude = /\.svg$/i;
// Formula rendering in markdown, replace dollar-sign with \(...\) and \[...\]
// https://github.com/remarkjs/remark-math/issues/39#issuecomment-2636184992
config.resolve.alias['micromark-extension-math'] =
'micromark-extension-llm-math';
return config;
},
resolveAlias: {
'micromark-extension-math': 'micromark-extension-llm-math',
},
},
};
+15 -16
View File
@@ -1,6 +1,6 @@
{
"name": "app-conversations",
"version": "0.0.13",
"version": "0.0.14",
"private": true,
"scripts": {
"dev": "next dev",
@@ -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",
@@ -40,11 +37,11 @@
"lottie-react": "^2.4.1",
"luxon": "3.6.1",
"micromark-extension-llm-math": "3.1.1-20250610",
"next": "15.3.9",
"next": "16.2.0",
"posthog-js": "1.249.3",
"react": "19.2.1",
"react": "19.2.4",
"react-aria-components": "1.9.0",
"react-dom": "19.2.1",
"react-dom": "19.2.4",
"react-i18next": "15.5.2",
"react-intersection-observer": "9.16.0",
"react-markdown": "10.1.0",
@@ -68,21 +65,23 @@
"@types/lodash": "4.17.17",
"@types/luxon": "3.6.2",
"@types/node": "*",
"@types/react": "*",
"@types/react-dom": "*",
"cross-env": "7.0.3",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"dotenv": "16.5.0",
"eslint": "^9",
"eslint-config-conversations": "*",
"eslint-config-next": "16.2.0",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"stylelint": "16.20.0",
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.99.9",
"workbox-webpack-plugin": "7.1.0"
"typescript": "*"
},
"resolutions": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.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';
@@ -92,6 +92,7 @@ export const ToastProvider = ({ children }: ToastProviderProps) => {
{isMounted &&
typeof document !== 'undefined' &&
document.body &&
toasts.length > 0 &&
createPortal(
<Box
aria-live="polite"
@@ -1,7 +1,6 @@
import { Command } from 'cmdk';
import { ReactNode, useRef } from 'react';
import { PropsWithChildren, ReactNode, useId, useRef, useState } from 'react';
import { useResponsiveStore } from '@/stores';
import { hasChildrens } from '@/utils/children';
import { Box } from '../Box';
@@ -25,80 +24,64 @@ export type QuickSearchData<T> = {
export type QuickSearchProps = {
onFilter?: (str: string) => void;
onClear?: () => void;
inputValue?: string;
inputContent?: ReactNode;
showInput?: boolean;
loading?: boolean;
label?: string;
placeholder?: string;
children?: ReactNode;
/** Affiche le séparateur sous l'input seulement quand il y a des résultats à afficher */
hasResults?: boolean;
};
export const QuickSearch = ({
onFilter,
onClear,
inputContent,
inputValue,
loading,
showInput = true,
label,
placeholder,
hasResults,
children,
}: QuickSearchProps) => {
}: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
const { isMobile, isTablet } = useResponsiveStore();
const listId = useId();
const [userInteracted, setUserInteracted] = useState(false);
const isExpanded = userInteracted;
const handleUserInteract = () => {
if (!userInteracted) {
setUserInteracted(true);
}
};
return (
<>
<QuickSearchStyle />
<Command label={label} shouldFilter={false} ref={ref}>
{showInput && (
<Box
$css={`
position: sticky;
top: 0px;
&:after {
content: "";
position: absolute;
width: 100%;
height: 20px;
bottom: -20px;
background: linear-gradient(
to bottom,
rgba(var(--c--contextuals--background--surface--tertiary), 1),
rgba(var(--c--contextuals--background--surface--tertiary), 0)
);
}
${
!isMobile && !isTablet
? `
&:before {
content: "";
background-color: var(--c--contextuals--background--surface--tertiary);
position: absolute;
width: 100%;
height: 20px;
top: -8px;
}
`
: ''
}
`}
>
<div className="quick-search-container">
<Command label={label} shouldFilter={false} ref={ref} tabIndex={-1}>
{showInput && (
<QuickSearchInput
withSeparator={hasChildrens(children)}
loading={loading}
withSeparator={
hasResults !== undefined ? hasResults : hasChildrens(children)
}
inputValue={inputValue}
onFilter={onFilter}
onClear={onClear}
placeholder={placeholder}
listId={listId}
isExpanded={isExpanded}
onUserInteract={handleUserInteract}
>
{inputContent}
</QuickSearchInput>
</Box>
)}
<Command.List>
<Box $padding={{ horizontal: 'xs', top: 'sm' }}>{children}</Box>
</Command.List>
</Command>
)}
<Command.List id={listId} aria-label={label} role="listbox">
<Box>{children}</Box>
</Command.List>
</Command>
</div>
</>
);
};
@@ -1,11 +1,7 @@
import { Command } from 'cmdk';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
import { Text } from '../Text';
import { Box, Text } from '@/components';
import { QuickSearchData } from './QuickSearch';
import { QuickSearchItem } from './QuickSearchItem';
@@ -21,22 +17,13 @@ export const QuickSearchGroup = <T,>({
onSelect,
renderElement,
}: Props<T>) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
return (
<Box>
<Command.Group key={group.groupName} forceMount={false}>
<Text
$size="sm"
$variation="700"
$margin={{ bottom: spacingsTokens['2xs'] }}
$padding={{ horizontal: 'xs' }}
$weight="700"
>
{t('Search results')}
</Text>
<Box $margin={{ all: 'sm' }}>
<Command.Group
key={group.groupName}
heading={group.groupName}
forceMount={false}
>
{group.startActions?.map((action, index) => {
return (
<QuickSearchItem
@@ -71,12 +58,7 @@ export const QuickSearchGroup = <T,>({
);
})}
{group.emptyString && group.elements.length === 0 && (
<Text
$size="sm"
$variation="500"
$margin={{ bottom: spacingsTokens['2xs'] }}
$padding={{ horizontal: 'xs' }}
>
<Text $margin={{ left: '2xs', bottom: '3xs' }} $size="sm">
{group.emptyString}
</Text>
)}
@@ -1,5 +1,6 @@
import { Loader } from '@gouvfr-lasuite/cunningham-react';
import { Command } from 'cmdk';
import { ReactNode, useCallback, useState } from 'react';
import { ReactNode, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { HorizontalSeparator } from '@/components';
@@ -9,54 +10,32 @@ import { Box } from '../Box';
import { Icon } from '../Icon';
type Props = {
loading?: boolean;
inputValue?: string;
onFilter?: (str: string) => void;
placeholder?: string;
children?: ReactNode;
withSeparator?: boolean;
onClear?: () => void;
listId?: string;
onUserInteract?: () => void;
isExpanded?: boolean;
};
export const QuickSearchInput = ({
loading,
inputValue,
onFilter,
placeholder,
children,
withSeparator: separator = true,
onClear,
listId,
onUserInteract,
isExpanded,
}: Props) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const [localValue, setLocalValue] = useState(inputValue || '');
const debouncedFilter = useCallback(
(value: string) => {
let timeoutId: NodeJS.Timeout;
const debounce = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
onFilter?.(value);
}, 150);
};
debounce();
},
[onFilter],
);
const handleChange = useCallback(
(value: string) => {
setLocalValue(value);
debouncedFilter(value);
},
[debouncedFilter],
);
const handleClear = useCallback(() => {
setLocalValue('');
onClear?.();
}, [onClear]);
const inputRef = useCallback((node: HTMLInputElement | null) => {
node?.focus();
}, []);
if (children) {
return (
@@ -72,80 +51,41 @@ export const QuickSearchInput = ({
<Box
$direction="row"
$align="center"
className="quick-search-input"
$gap={spacingsTokens['2xs']}
$css={`
justify-content: space-between;
border-radius: 4px;
margin: 12px 12px 0 12px;
border: 1px solid var(--c--contextuals--border--semantic--neutral--tertiary);
background-color: var(--c--contextuals--background--semantic--neutral--tertiary);
padding: 6px 8px;
cursor: pointer !important;
transition: border-color 0.2s ease;
&:hover {
border: 2px solid var(--c--contextuals--border--semantic--neutral--tertiary);
padding: 5px 7px;
}
&:focus-within {
border: 2px solid var(--c--contextuals--border--semantic--brand--primary);
padding: 5px 7px;
}
& input {
font-family: 'Marianne' !important;
border: none;
font-size: 0.9rem;
color: var(--c--contextuals--content--semantic--neutral--primary);
font-weight: 400;
background-color: transparent;
&:focus {
border: none;
outline: none;
}
&::placeholder {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
}
`}
$padding={{ horizontal: 'base', bottom: 'sm', top: 'base' }}
>
<Box $direction="row" $gap="6px">
<Icon iconName="search" $theme="neutral" $variation="tertiary" />
<Command.Input
aria-label={t('Quick search input')}
value={localValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={handleChange}
{loading ? (
<div>
<Loader size="small" />
</div>
) : (
<Icon
iconName="search"
$theme="neutral"
$variation="secondary"
aria-hidden="true"
/>
</Box>
<Box
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClear();
}
)}
<Command.Input
ref={inputRef}
aria-label={t('Quick search input')}
aria-expanded={isExpanded}
aria-controls={listId}
onClick={(e) => {
e.stopPropagation();
onUserInteract?.();
}}
role="button"
tabIndex={0}
aria-label={t('Clear search')}
$css={`cursor: pointer;
opacity: ${localValue && localValue.length > 0 && onClear ? 1 : 0};
transition: opacity 0.3s cubic-bezier(1, 0, 0, 1);
right: 10px;
border-radius: 4px;
padding: 7px;
z-index: 1000;
&:hover {
background-color: var(--c--contextuals--background--semantic--neutral--secondary);
}
&:focus-visible {
outline: 2px solid var(--c--contextuals--border--semantic--brand--primary);
outline-offset: 2px;
}`}
>
<Icon iconName="close" $size="15px" $variation="600" />
</Box>
onKeyDown={() => onUserInteract?.()}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
maxLength={254}
data-testid="quick-search-input"
/>
</Box>
{separator && <HorizontalSeparator $withPadding={false} />}
</>
);
};
@@ -24,8 +24,9 @@ export const QuickSearchItemContent = ({
<Box
$direction="row"
$align="center"
$padding={{ horizontal: '2xs', vertical: '3xs' }}
$padding={{ horizontal: '2xs', vertical: '4xs' }}
$justify="space-between"
$minHeight="34px"
$width="100%"
>
<Box
@@ -0,0 +1,83 @@
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`
font-weight: 400;
`;
type QuickSearchResultItemProps = {
conversation: ChatConversation;
};
export const QuickSearchResultItem = memo(function QuickSearchResultItem({
conversation,
}: QuickSearchResultItemProps) {
const { t, i18n } = useTranslation();
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"
$align="center"
$gap={spacingsTokens.sm}
$width="100%"
$padding={{ vertical: 'xxs', horizontal: 'xs' }}
>
<BubbleIcon aria-hidden="true" color="brand" />
<Box $flex={1} $direction="column" $minWidth={0}>
<Text aria-label={title} $size="sm" $shrink={1}>
{title}
</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"
$shrink={0}
$css="color: var(--c--contextuals--content--semantic--brand--primary);"
>
<ArrowForwardIcon aria-hidden width={18} height={12} />
</Box>
</Box>
);
});
@@ -1,127 +1,137 @@
import { createGlobalStyle } from 'styled-components';
export const QuickSearchStyle = createGlobalStyle`
& *:focus-visible {
outline: none;
}
.quick-search-container {
position: relative;
padding-top: 0;
[cmdk-root] {
width: 100%;
position: relative;
border-radius: 12px;
overflow: hidden;
transition: transform 100ms ease;
outline: none;
border: none;
}
[cmdk-input] {
border: none;
input::placeholder {
font-size: 14px;
color: var(--c--contextuals--content--semantic--neutral--tertiary) !important;
}
}
[cmdk-item] {
content-visibility: auto;
cursor: pointer;
border-radius: var(--c--theme--spacings--xs);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
will-change: background, color;
transition: all 150ms ease;
transition-property: none;
.show-right-on-focus {
opacity: 0;
width: 100%;
overflow: hidden;
transition: transform 100ms ease;
outline: none;
}
&:hover,
&[data-selected='true'] {
background: var(--c--theme--colors--gray-100);
.show-right-on-focus {
opacity: 1;
[cmdk-input] {
border: none;
width: 100%;
font-size: 1rem;
background-color: var(--c--components--modal--background-color) !important;
font-weight: 400;
outline: none;
color: var(--c--contextuals--content--semantic--neutral--primary);
border-radius: var(--c--globals--spacings--0);
&::placeholder {
color: var(--c--contextuals--content--semantic--neutral--tertiary);
font-weight: 400;
}
}
&[data-disabled='true'] {
color: var(--c--theme--colors--gray-500);
cursor: not-allowed;
}
& + [cmdk-item] {
margin-top: 4px;
}
}
[cmdk-list] {
padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base)
var(--c--theme--spacings--base);
flex:1;
overflow-y: auto;
overscroll-behavior: contain;
}
[cmdk-vercel-shortcuts] {
display: flex;
margin-left: auto;
gap: 8px;
kbd {
font-size: 12px;
min-width: 20px;
padding: 4px;
height: 20px;
border-radius: 4px;
color: white;
background: var(--c--contextuals--content--semantic--neutral--tertiary);
display: inline-flex;
[cmdk-item] {
content-visibility: auto;
cursor: pointer;
border-radius: var(--c--globals--spacings--xs);
font-size: var(--c--globals--font--sizes--sm);
display: flex;
font-weight: 500;
align-items: center;
justify-content: center;
text-transform: uppercase;
gap: var(--c--globals--spacings--xs);
user-select: none;
will-change: background, color;
transition: all 150ms ease;
.show-right-on-focus {
opacity: 0;
}
&:hover,
&[data-selected='true'] {
background: var(--c--contextuals--background--semantic--overlay--primary);
font-weight: 700;
.show-right-on-focus {
opacity: 1;
}
}
&[data-disabled='true'] {
color: var(--c--globals--colors--gray-500);
cursor: not-allowed;
}
& + [cmdk-item] {
margin-top: var(--c--globals--spacings--st);
}
}
[cmdk-list] {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
}
[cmdk-vercel-shortcuts] {
display: flex;
margin-left: auto;
gap: 8px;
kbd {
font-size: 12px;
min-width: 20px;
padding: var(--c--globals--spacings--st);
height: 20px;
border-radius: var(--c--globals--spacings--st);
color: white;
background: var(--c--globals--colors--gray-500);
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
}
[cmdk-separator] {
height: 1px;
width: 100%;
background: var(--c--contextuals--border--surface--primary);
margin: var(--c--globals--spacings--st) 0;
}
*:not([hidden]) + [cmdk-group] {
margin-top: var(--c--globals--spacings--xs);
}
[cmdk-group-heading] {
user-select: none;
font-size: var(--c--globals--font--sizes--sm);
color: var(--c--contextuals--content--semantic--neutral--primary);
font-weight: bold;
display: flex;
align-items: center;
margin-bottom: var(--c--globals--spacings--xs);
}
[cmdk-empty] {
}
}
[cmdk-separator] {
height: 1px;
width: 100%;
background: transparent;
margin: 4px 0;
}
.c__modal__scroller:has(.quick-search-container),
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
.c__modal__close .c__button {
right: 5px;
top: 5px;
padding: 1.5rem 1rem;
}
*:not([hidden]) + [cmdk-group] {
margin-top: 8px;
.c__modal__title {
font-size: var(--c--globals--font--sizes--xs);
padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--base) var(--c--globals--spacings--xs) var(--c--globals--spacings--base);
margin-bottom: var(--c--globals--spacings--0);
}
}
[cmdk-group-heading] {
padding: 0 var(--c--theme--spacings--xs);
user-select: none;
font-size: 12px;
color: var(--c--theme--colors--gray-700);
font-weight: 700;;
margin-bottom: var(--c--theme--spacings--xs);
}
}
.c__modal__scroller:has(.quick-search-container),
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
.c__modal__close .c__button {
right: 5px;
top: 5px;
padding: 1.5rem 1rem;
}
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--base);
margin-bottom: 0;
}
}
`;
@@ -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();
});
});
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="12" viewBox="0 0 18 12" fill="none">
<path d="M6 12L0 6L6 0L7.4 1.4L3.8 5H16V1H18V7H3.8L7.4 10.6L6 12Z" fill="var(--c--contextuals--content--semantic--brand--secondary)"/>
</svg>

After

Width:  |  Height:  |  Size: 240 B

@@ -0,0 +1,21 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_14806_4306)">
<path d="M10.3206 3.05884C9.09668 3.0589 8.02564 3.29844 7.11938 3.79224C6.21362 4.27805 5.51854 4.97694 5.04028 5.88306L4.86938 6.23071C4.49869 7.05854 4.31763 8.0089 4.31763 9.07349V18.2629C4.31768 19.4785 4.55523 20.544 5.04028 21.4495L5.03931 21.4504C5.51759 22.3575 6.21295 23.057 7.11938 23.5432C8.02564 24.0293 9.09655 24.2658 10.3206 24.2659H17.5325L21.7942 28.0491C22.1206 28.3434 22.4212 28.5681 22.6946 28.7092L22.6956 28.7083C22.9781 28.8617 23.2792 28.9417 23.595 28.9417C24.0553 28.9416 24.4509 28.7907 24.7385 28.4622C25.0191 28.1415 25.1418 27.7226 25.1418 27.2434V24.2659H25.8801C27.0015 24.2659 28.0071 24.0269 28.8879 23.5413H28.8889C29.7627 23.0549 30.4509 22.3571 30.9504 21.4563L31.1252 21.1067C31.5023 20.2786 31.6857 19.328 31.6858 18.2629V9.07349C31.6858 7.85667 31.4501 6.78913 30.9641 5.88306C30.4788 4.97818 29.7847 4.27936 28.887 3.79321C27.9802 3.29859 26.9079 3.05884 25.6829 3.05884H10.3206Z" fill="white"/>
<path d="M10.3206 3.05884C9.09668 3.0589 8.02564 3.29844 7.11938 3.79224C6.21362 4.27805 5.51854 4.97694 5.04028 5.88306L4.86938 6.23071C4.49869 7.05854 4.31763 8.0089 4.31763 9.07349V18.2629C4.31768 19.4785 4.55523 20.544 5.04028 21.4495L5.03931 21.4504C5.51759 22.3575 6.21295 23.057 7.11938 23.5432C8.02564 24.0293 9.09655 24.2658 10.3206 24.2659H17.5325L21.7942 28.0491C22.1206 28.3434 22.4212 28.5681 22.6946 28.7092L22.6956 28.7083C22.9781 28.8617 23.2792 28.9417 23.595 28.9417C24.0553 28.9416 24.4509 28.7907 24.7385 28.4622C25.0191 28.1415 25.1418 27.7226 25.1418 27.2434V24.2659H25.8801C27.0015 24.2659 28.0071 24.0269 28.8879 23.5413H28.8889C29.7627 23.0549 30.4509 22.3571 30.9504 21.4563L31.1252 21.1067C31.5023 20.2786 31.6857 19.328 31.6858 18.2629V9.07349C31.6858 7.85667 31.4501 6.78913 30.9641 5.88306C30.4788 4.97818 29.7847 4.27936 28.887 3.79321C27.9802 3.29859 26.9079 3.05884 25.6829 3.05884H10.3206Z" stroke="var(--c--contextuals--background--palette--gray--primary)" stroke-width="0.635623"/>
<path d="M10.3206 3.05884C9.09668 3.0589 8.02564 3.29844 7.11938 3.79224C6.21362 4.27805 5.51854 4.97694 5.04028 5.88306L4.86938 6.23071C4.49869 7.05854 4.31763 8.0089 4.31763 9.07349V18.2629C4.31768 19.4785 4.55523 20.544 5.04028 21.4495L5.03931 21.4504C5.51759 22.3575 6.21295 23.057 7.11938 23.5432C8.02564 24.0293 9.09655 24.2658 10.3206 24.2659H17.5325L21.7942 28.0491C22.1206 28.3434 22.4212 28.5681 22.6946 28.7092L22.6956 28.7083C22.9781 28.8617 23.2792 28.9417 23.595 28.9417C24.0553 28.9416 24.4509 28.7907 24.7385 28.4622C25.0191 28.1415 25.1418 27.7226 25.1418 27.2434V24.2659H25.8801C27.0015 24.2659 28.0071 24.0269 28.8879 23.5413H28.8889C29.7627 23.0549 30.4509 22.3571 30.9504 21.4563L31.1252 21.1067C31.5023 20.2786 31.6857 19.328 31.6858 18.2629V9.07349C31.6858 7.85667 31.4501 6.78913 30.9641 5.88306C30.4788 4.97818 29.7847 4.27936 28.887 3.79321C27.9802 3.29859 26.9079 3.05884 25.6829 3.05884H10.3206Z" stroke="#F6F8F9" stroke-opacity="0.8" stroke-width="0.635623"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.08569 8.42063C8.08569 7.92971 8.48366 7.53174 8.97458 7.53174L27.0293 7.53174C27.5202 7.53174 27.9182 7.92971 27.9182 8.42063C27.9182 8.91155 27.5202 9.30952 27.0293 9.30952L8.97458 9.30952C8.48366 9.30952 8.08569 8.91155 8.08569 8.42063ZM8.08569 11.0873C8.08569 10.5964 8.48366 10.1984 8.97458 10.1984H27.0293C27.5202 10.1984 27.9182 10.5964 27.9182 11.0873C27.9182 11.5782 27.5202 11.9762 27.0293 11.9762H8.97458C8.48366 11.9762 8.08569 11.5782 8.08569 11.0873ZM8.08569 13.754C8.08569 13.263 8.48366 12.8651 8.97458 12.8651H27.0293C27.5202 12.8651 27.9182 13.263 27.9182 13.754C27.9182 14.2449 27.5202 14.6428 27.0293 14.6428L8.97458 14.6428C8.48366 14.6428 8.08569 14.2449 8.08569 13.754ZM8.08569 16.4206C8.08569 15.9297 8.48366 15.5317 8.97458 15.5317H16.0293C16.5202 15.5317 16.9182 15.9297 16.9182 16.4206C16.9182 16.9115 16.5202 17.3095 16.0293 17.3095H8.97458C8.48366 17.3095 8.08569 16.9115 8.08569 16.4206Z" fill="var(--c--contextuals--background--palette--gray--primary)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.08569 8.42063C8.08569 7.92971 8.48366 7.53174 8.97458 7.53174L27.0293 7.53174C27.5202 7.53174 27.9182 7.92971 27.9182 8.42063C27.9182 8.91155 27.5202 9.30952 27.0293 9.30952L8.97458 9.30952C8.48366 9.30952 8.08569 8.91155 8.08569 8.42063ZM8.08569 11.0873C8.08569 10.5964 8.48366 10.1984 8.97458 10.1984H27.0293C27.5202 10.1984 27.9182 10.5964 27.9182 11.0873C27.9182 11.5782 27.5202 11.9762 27.0293 11.9762H8.97458C8.48366 11.9762 8.08569 11.5782 8.08569 11.0873ZM8.08569 13.754C8.08569 13.263 8.48366 12.8651 8.97458 12.8651H27.0293C27.5202 12.8651 27.9182 13.263 27.9182 13.754C27.9182 14.2449 27.5202 14.6428 27.0293 14.6428L8.97458 14.6428C8.48366 14.6428 8.08569 14.2449 8.08569 13.754ZM8.08569 16.4206C8.08569 15.9297 8.48366 15.5317 8.97458 15.5317H16.0293C16.5202 15.5317 16.9182 15.9297 16.9182 16.4206C16.9182 16.9115 16.5202 17.3095 16.0293 17.3095H8.97458C8.48366 17.3095 8.08569 16.9115 8.08569 16.4206Z" fill="#F6F8F9" fill-opacity="0.65"/>
</g>
<defs>
<filter id="filter0_d_14806_4306" x="-1.99805" y="-2" width="40" height="40" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_14806_4306"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_14806_4306" result="shape"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

@@ -2,3 +2,4 @@ export * from './QuickSearch';
export * from './QuickSearchGroup';
export * from './QuickSearchItem';
export * from './QuickSearchItemContent';
export * from './QuickSearchResultItem';
@@ -1,5 +1,3 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
@@ -16,8 +14,6 @@ export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
$withPadding = true,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
@@ -26,7 +22,7 @@ export const HorizontalSeparator = ({
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens['gray-100']
: 'var(--c--contextuals--border--surface--primary)'
}
className="--docs--horizontal-separator"
/>
@@ -1,8 +1,6 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
@@ -13,12 +11,10 @@ export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const { spacingsTokens } = useCunninghamTheme();
return (
<Box
$css={css`
width: 100%;
padding: ${spacingsTokens['sm']} 0;
${showSeparator &&
css`
border-bottom: 1px solid
@@ -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,8 +1,7 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
DefinedInitialDataInfiniteOptionsAPI,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
@@ -42,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) {
@@ -56,21 +59,16 @@ export const getConversations = async (
export const KEY_LIST_CONVERSATION = 'conversations';
export function useConversations(
param: ConversationsParams,
queryConfig?: UseQueryOptions<
ConversationsResponse,
APIError,
ConversationsResponse
export const useInfiniteConversations = (
params: ConversationsParams,
queryConfig?: Partial<
DefinedInitialDataInfiniteOptionsAPI<ConversationsResponse>
>,
) {
return useQuery<ConversationsResponse, APIError, ConversationsResponse>({
queryKey: [KEY_LIST_CONVERSATION, param],
queryFn: () => getConversations(param),
...queryConfig,
});
}
export const useInfiniteConversations = (params: ConversationsParams) => {
return useAPIInfiniteQuery(KEY_LIST_CONVERSATION, getConversations, params);
) => {
return useAPIInfiniteQuery(
KEY_LIST_CONVERSATION,
getConversations,
params,
queryConfig as DefinedInitialDataInfiniteOptionsAPI<ConversationsResponse>,
);
};
@@ -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';
@@ -44,7 +45,7 @@ interface InputChatProps {
const STYLES = {
form: { width: '100%' },
formPadding: { bottom: 'base' },
formPadding: { bottom: 'sm' },
formPaddingMobile: { bottom: '' },
attachmentMargin: { horizontal: '0', bottom: 'xs', top: 'xs' },
attachmentPadding: { horizontal: 'base' },
@@ -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}>
@@ -507,6 +510,19 @@ export const InputChat = ({
</Box>
</Box>
</form>
{isDesktop && (
<Box>
<Text
$textAlign="center"
$size="xs"
$color="var(--c--contextuals--content--semantic--neutral--tertiary)"
>
{t(
'The Assistant can make mistakes. Please verify any important information.',
)}
</Text>
</Box>
)}
</Box>
</>
);
@@ -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';

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