Compare commits

...

18 Commits

Author SHA1 Message Date
Arnaud Robin 9ee9f9152c Add real time api calling 2025-02-12 23:36:33 +01:00
Arnaud Robin b7914dfeef WIP 2025-02-12 15:42:10 +01:00
Nathan Vasse 1b2f74660b wip 2025-02-12 14:35:23 +01:00
Nathan Vasse 25491aeb06 wip 2025-02-12 14:09:48 +01:00
Arnaud Robin 6bb56e6b72 WIP 2025-02-12 13:14:24 +01:00
Nathan Vasse cdf157c72b wip 2025-02-11 16:51:42 +01:00
Nathan Vasse e26fcdf985 wip 2025-02-11 15:53:29 +01:00
Anthony LC 5cc4b07cf6 💄(frontend) improve caret style
Improve the caret, to looks more like the
google doc caret.
2025-02-10 15:53:03 +01:00
Anthony LC 0cfc242e09 🚚(frontend) move Blocknote styles
Move Blocknote styles to a separate file.
2025-02-10 15:53:03 +01:00
Anthony LC a6b3cfdb0c (frontend) add export page break
Blocknotejs introduced the ability to export a
document with page breaks.
This commit adds the page break feature to the
editor and so to our export feature.
2025-02-10 15:53:03 +01:00
Anthony LC 5ead18c94c 💬(frontend) add lacking buttons open source section
Some buttons were lacking in the open source
section of the home page. This commit adds them.
2025-02-10 15:40:23 +01:00
AntoLC 5eeb8cae5c 🌐(i18n) update translated strings
Update translated files with new translations
2025-02-10 15:40:23 +01:00
Jacques ROUSSEL 68bf024005 (helm) add pdbs to deployments
In order to avoid a service interruption during a Kubernetes (k8s)
upgrade, we add a Pod Disruption Budget (PDB) to deployments.
2025-02-10 13:05:54 +01:00
Anthony LC fdd1068c90 (frontend) fix test copy html
The recent upgrade of Blocknote has caused the
test to fail.
This commit updates the test to reflect
the new html structure.
2025-02-10 10:50:29 +01:00
Anthony LC ba695bf647 ⬇️(frontend) downgrade to @react-pdf/renderer to 4.1.6
The version 4.2.1 of @react-pdf/renderer
is not compatible @blocknote/xl-pdf-exporter
and @blocknote/xl-docx-exporter.
2025-02-10 10:50:29 +01:00
Anthony LC 27e7aec193 💄(frontend) improve styles export pdf
When exporting a document to PDF, the headings
spacings were too small, the break lines were
not displayed. This commit fixes these issues
by replacing the needed blocks.
2025-02-10 10:50:29 +01:00
Anthony LC 58b712a1de 🐛(frontend) fix cursor breakline
We had breakline issues with the initial
cursor because of some css properties.
We changed the cursor css to not take
any space in the lines,
avoiding the breakline issues.
We keep the new cursor visibility
feature (always, activity).
2025-02-10 10:50:29 +01:00
renovate[bot] 08f9036523 ⬆️(dependencies) update js dependencies 2025-02-10 10:50:29 +01:00
46 changed files with 41594 additions and 14783 deletions
+5 -1
View File
@@ -13,11 +13,15 @@ and this project adheres to
- 📝(doc) Add security.md and codeofconduct.md #604
- ✨(frontend) add home page #553
- ✨(frontend) cursor display on activity #609
- ✨(frontend) Add export page break #623
## Fixed
🌐(CI) Fix email partially translated #616
- 🐛(frontend) fix cursor breakline #609
- 🐛(frontend) fix style pdf export #609
## [2.1.0] - 2025-01-29
+12 -11
View File
@@ -21,8 +21,8 @@ services:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
- "9000:9000"
- "9001:9001"
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -59,11 +59,11 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
- postgresql
- mailcatcher
- redis
- createbuckets
- postgresql
- mailcatcher
- redis
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
image: impress:backend-development
@@ -122,7 +122,7 @@ services:
frontend-dev:
user: "${DOCKER_USER:-1000}"
build:
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
@@ -151,13 +151,13 @@ services:
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
y-provider:
user: ${DOCKER_USER:-1000}
build:
build:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider
@@ -169,6 +169,7 @@ services:
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:
@@ -196,7 +197,7 @@ services:
KC_DB_PASSWORD: pass
KC_DB_USERNAME: impress
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
PROXY_ADDRESS_FORWARDING: "true"
ports:
- "8080:8080"
depends_on:
+7
View File
@@ -10,6 +10,10 @@ LOGGING_LEVEL_HANDLERS_CONSOLE=INFO
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Y-Provider
Y_PROVIDER_API_KEY="yprovider-api-key"
Y_PROVIDER_API_BASE_URL=http://y-provider:4444/api/
# Python
PYTHONPATH=/app
@@ -54,6 +58,9 @@ AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Accessibility API
ACCESSIBILITY_API_BASE_URL=https://localhost:8000
# Collaboration
COLLABORATION_API_URL=http://nginx:8083/collaboration/api/
COLLABORATION_SERVER_ORIGIN=http://localhost:3000
+52 -1
View File
@@ -16,6 +16,7 @@ from core.services.converter_services import (
ConversionError,
YdocConverter,
)
from core.services.ai_services import AIService
class UserSerializer(serializers.ModelSerializer):
@@ -306,7 +307,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
if user:
email = user.email
language = user.language or language
try:
document_content = YdocConverter().convert_markdown(
validated_data["content"]
@@ -568,6 +569,56 @@ class AITranslateSerializer(serializers.Serializer):
if len(value.strip()) == 0:
raise serializers.ValidationError("Text field cannot be empty.")
return value
class AIPdfTranscribeSerializer(serializers.Serializer):
"""Serializer for AI PDF transcribe requests."""
pdfUrl = serializers.CharField(required=True)
def __init__(self, *args, **kwargs):
"""Initialize with user."""
self.user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
def validate_pdfUrl(self, value):
"""Ensure the pdfUrl field is a valid URL."""
if not value.startswith(settings.MEDIA_BASE_URL):
raise serializers.ValidationError("Invalid PDF URL format.")
return value
def create(self, validated_data):
"""Create a new document for the transcribed content."""
if not self.user:
raise serializers.ValidationError("User is required")
# Get the transcribed content from AI service
pdf_url = validated_data["pdfUrl"]
response = AIService().transcribe_pdf(pdf_url)
try:
# Convert the markdown content to YDoc format
document_content = YdocConverter().convert_markdown(response)
except ConversionError as err:
raise serializers.ValidationError(
{"content": [f"Could not convert transcribed content: {str(err)}"]}
) from err
# Create the document as root node with converted content
document = models.Document.add_root(
title="PDF Transcription",
content=document_content,
creator=self.user,
)
# Create owner access for the user
models.DocumentAccess.objects.create(
document=document,
role=models.RoleChoices.OWNER,
user=self.user,
)
return document
class MoveDocumentSerializer(serializers.Serializer):
+61
View File
@@ -1079,6 +1079,41 @@ class DocumentViewSet(
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Just proxy ai call",
url_path="ai-proxy"
)
def ai_proxy(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-transform
with expected data:
- text: str
- action: str [prompt, correct, rephrase, summarize]
Return JSON response with the processed text.
"""
print('PROXY 1')
# Check permissions first
# self.get_object()
print('PROXY 2')
print(request.data)
# serializer = serializers.AITransformSerializer(data=request.data)
# serializer.is_valid(raise_exception=True)
print('PROXY 3')
system_content = request.data["system"]
text = request.data["text"]
print('PROXY 4')
response = AIService().call_proxy(system_content, text)
print('PROXY 5')
print(response)
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
@@ -1107,6 +1142,32 @@ class DocumentViewSet(
return drf.response.Response(response, status=drf.status.HTTP_200_OK)
@drf.decorators.action(
detail=True,
methods=["post"],
name="Transcribe PDF with AI",
url_path="ai-pdf-transcribe",
throttle_classes=[utils.AIDocumentRateThrottle, utils.AIUserRateThrottle],
)
def ai_pdf_transcribe(self, request, *args, **kwargs):
"""
POST /api/v1.0/documents/<resource_id>/ai-pdf-transcribe
with expected data:
- pdfUrl: str
Return JSON response with the new document ID containing the transcription.
"""
serializer = serializers.AIPdfTranscribeSerializer(
data=request.data,
user=request.user
)
serializer.is_valid(raise_exception=True)
document = serializer.save()
return drf.response.Response(
{"document_id": str(document.id)},
status=drf.status.HTTP_201_CREATED
)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
+44
View File
@@ -2,13 +2,18 @@
import json
import re
import os
import requests
import botocore
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.files.storage import default_storage
from openai import OpenAI
from core import enums
from core.models import Document
AI_ACTIONS = {
"prompt": (
@@ -55,6 +60,22 @@ class AIService:
raise ImproperlyConfigured("AI configuration not set")
self.client = OpenAI(base_url=settings.AI_BASE_URL, api_key=settings.AI_API_KEY)
def call_proxy(self, system_content, text):
messages = [
{"role": "system", "content": system_content},
{"role": "user", "content": text},
]
print('REQUEST', messages)
response = self.client.chat.completions.create(
model=settings.AI_MODEL,
messages=messages,
)
print('RESPONSE', response)
content = response.choices[0].message.content
print('CONTENT', content)
return content
def call_ai_api(self, system_content, text):
"""Helper method to call the OpenAI API and process the response."""
response = self.client.chat.completions.create(
@@ -96,3 +117,26 @@ class AIService:
language_display = enums.ALL_LANGUAGES.get(language, language)
system_content = AI_TRANSLATE.format(language=language_display)
return self.call_ai_api(system_content, text)
def transcribe_pdf(self, pdf_url):
"""Transcribe PDF using the accessibility hackathon API and create a new document."""
try:
media_prefix = os.path.join(settings.MEDIA_BASE_URL, "media")
key = pdf_url[len(media_prefix):]
pdf_response = default_storage.connection.meta.client.get_object(
Bucket=default_storage.bucket_name,
Key=key
)
pdf_content = pdf_response['Body'].read()
api_url = f"{settings.ACCESSIBILITY_API_BASE_URL}/transcribe/pdf"
files = {'file': ('document.pdf', pdf_content, 'application/pdf')}
headers = {'Accept': 'application/json'}
response = requests.post(api_url, files=files, headers=headers)
response.raise_for_status()
transcribed_text = response.json()['markdown_content']
return transcribed_text
except Exception as e:
raise RuntimeError(f"Failed to transcribe PDF: {str(e)}")
+6
View File
@@ -517,6 +517,12 @@ class Base(Configuration):
AI_BASE_URL = values.Value(None, environ_name="AI_BASE_URL", environ_prefix=None)
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
ACCESSIBILITY_API_BASE_URL = values.Value(
None,
environ_name="ACCESSIBILITY_API_BASE_URL",
environ_prefix=None,
)
AI_DOCUMENT_RATE_THROTTLE_RATES = {
"minute": 5,
"hour": 100,
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:59\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:57\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:59\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-06 15:30+0000\n"
"PO-Revision-Date: 2025-02-06 15:57\n"
"PO-Revision-Date: 2025-02-10 14:14\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -41,8 +41,16 @@ test.describe('Doc Export', () => {
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
test('it exports the doc to pdf', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
test('it exports the doc with pdf line break', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-line-break',
browserName,
1,
);
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
@@ -50,8 +58,20 @@ test.describe('Doc Export', () => {
await verifyDocName(page, randomDoc);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const editor = page.locator('.ProseMirror.bn-editor');
await editor.click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Page Break').click();
await expect(editor.locator('.bn-page-break')).toBeVisible();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await page
.getByRole('button', {
@@ -69,9 +89,10 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
const pdfText = (await pdf(pdfBuffer)).text;
const pdfData = await pdf(pdfBuffer);
expect(pdfText).toContain('Hello World'); // This is the doc text
expect(pdfData.numpages).toBe(2);
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
});
test('it exports the doc to docx', async ({ page, browserName }) => {
@@ -395,9 +395,7 @@ test.describe('Doc Header', () => {
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(
`<h1 data-level=\"1\">Hello World</h1><p></p>`,
);
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
});
test('it checks the copy link button', async ({ page }) => {
+1 -1
View File
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.50.0",
"@playwright/test": "1.50.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+13 -12
View File
@@ -15,34 +15,35 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.21.0",
"@blocknote/mantine": "0.21.0",
"@blocknote/react": "0.21.0",
"@blocknote/xl-docx-exporter": "0.21.0",
"@blocknote/xl-pdf-exporter": "0.21.0",
"@blocknote/core": "0.23.2",
"@blocknote/mantine": "0.23.2",
"@blocknote/react": "0.23.2",
"@blocknote/xl-docx-exporter": "0.23.2",
"@blocknote/xl-pdf-exporter": "0.23.2",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.1",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "2.9.4",
"@react-pdf/renderer": "4.1.6",
"@sentry/nextjs": "8.52.0",
"@tanstack/react-query": "5.65.1",
"@sentry/nextjs": "8.54.0",
"@tanstack/react-query": "5.66.0",
"cmdk": "1.0.4",
"crisp-sdk-web": "1.0.25",
"docx": "9.1.1",
"i18next": "24.2.2",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.1",
"idb": "8.0.2",
"lodash": "4.17.21",
"luxon": "3.5.0",
"marked": "^15.0.7",
"next": "15.1.6",
"posthog-js": "1.211.3",
"posthog-js": "1.215.6",
"react": "*",
"react-aria-components": "1.6.0",
"react-dom": "*",
"react-i18next": "15.4.0",
"react-intersection-observer": "9.15.1",
"react-select": "5.10.0",
"styled-components": "6.1.14",
"styled-components": "6.1.15",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "13.6.23",
@@ -50,7 +51,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.65.1",
"@tanstack/react-query-devtools": "5.66.0",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.2.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

@@ -0,0 +1,39 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export type DocAIPdfTranscribe = {
docId: string;
pdfUrl: string;
};
export type DocAIPdfTranscribeResponse = {
document_id: string;
};
export const docAIPdfTranscribe = async ({
docId,
...params
}: DocAIPdfTranscribe): Promise<DocAIPdfTranscribeResponse> => {
const response = await fetchAPI(`documents/${docId}/ai-pdf-transcribe/`, {
method: 'POST',
body: JSON.stringify({
...params,
}),
});
if (!response.ok) {
throw new APIError(
'Failed to request pdf transcription',
await errorCauses(response),
);
}
return response.json() as Promise<DocAIPdfTranscribeResponse>;
};
export function useDocAIPdfTranscribe() {
return useMutation<DocAIPdfTranscribeResponse, APIError, DocAIPdfTranscribe>({
mutationFn: docAIPdfTranscribe,
});
}
@@ -0,0 +1,88 @@
import {
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import {
Loader,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Text } from '@/components';
import { useDocStore } from '@/features/docs/doc-management/';
import { useDocAIPdfTranscribe } from '../api/useDocAIPdfTranscribe';
export const AIPdfButton = () => {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const { t } = useTranslation();
const { currentDoc } = useDocStore();
const { toast } = useToastProvider();
const router = useRouter();
const { mutateAsync: requestAIPdf, isPending } = useDocAIPdfTranscribe();
const [isLoading, setIsLoading] = useState(false);
if (!Components || !currentDoc) {
return null;
}
const show = selectedBlocks.length === 1 && selectedBlocks[0].type === 'file';
if (!show) {
return null;
}
const handlePdfTranscription = async () => {
console.log('selectedBlocks', selectedBlocks);
const pdfBlock = selectedBlocks[0];
const props = pdfBlock.props as { url?: string };
const pdfUrl = props?.url;
console.log('pdfUrl', pdfUrl);
if (!props || !pdfUrl) {
toast(t('No PDF file found'), VariantType.ERROR);
return;
}
setIsLoading(true);
try {
const response = await requestAIPdf({
docId: currentDoc.id,
pdfUrl,
});
setTimeout(() => {
// router.push causes the following error:
// TypeError: Cannot read properties of undefined (reading 'isDestroyed')
// void router.push(`/docs/${response.document_id}`);
window.location.href = `/docs/${response.document_id}?albert=true`;
}, 1000);
} catch (error) {
console.error('error', error);
toast(t('Failed to transcribe PDF'), VariantType.ERROR);
}
};
return (
<Components.FormattingToolbar.Button
className="bn-button bn-menu-item"
data-test="ai-pdf-transcribe"
label="AI"
mainTooltip={t('Transcribe PDF')}
icon={
isLoading ? (
<Loader size="small" />
) : (
<Text $isMaterialIcon $size="l">
auto_awesome
</Text>
)
}
onClick={() => void handlePdfTranscription()}
/>
);
};
@@ -0,0 +1,303 @@
import { Button, Input, Loader } from '@openfun/cunningham-react';
import { marked } from 'marked';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import styled from 'styled-components';
import { fetchAPI } from '@/api';
import { Box, Text } from '@/components';
import { Doc } from '@/features/docs';
import { useEditorStore } from '../../stores/useEditorStore';
export const AIButtonEl = styled.button`
background-image: url('/assets/ia_baguette.png');
background-size: cover;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
background-color: transparent;
`;
export const SuggestionButton = styled.button`
display: flex;
align-items: center;
border: none;
cursor: pointer;
background-color: white;
color: var(--c--theme--colors--greyscale-900);
height: 32px;
padding: 0 0.5rem;
border-radius: 4px;
font-weight: 600;
&:hover,
&:focus {
background-color: var(--c--theme--colors--greyscale-100);
}
span.material-icons {
margin-right: 4px;
}
span.sub {
color: var(--c--theme--colors--greyscale-600);
margin-left: 4px;
font-weight: 500;
}
`;
export const AiButton = ({ doc }: { doc: Doc }) => {
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(searchParams.get('albert') === 'true');
return (
<>
<Box
$position="absolute"
$css={`
right: 0;
bottom: 0;
padding: 1rem;
margin: 1rem;
z-index: 1;
`}
>
<AIButtonEl
aria-label="Posez une question à Albert à propos de ce document"
onClick={() => setIsOpen(true)}
/>
</Box>
<AiChat doc={doc} isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
};
type Message = {
role: 'user' | 'assistant';
content: string;
};
const AiChat = (props: { isOpen: boolean; onClose: () => void; doc: Doc }) => {
const [prompt, setPrompt] = useState('');
const { editor } = useEditorStore();
const [isLoading, setIsLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
if (!props.isOpen) {
return null;
}
const newPrompt = async (prompt: string) => {
if (!editor) {
return;
}
setIsLoading(true);
setMessages([...messages, { role: 'user', content: prompt }]);
const editorContentFormatted = await editor.blocksToMarkdownLossy();
const response = await fetchAPI(`documents/${props.doc.id}/ai-proxy/`, {
method: 'POST',
body: JSON.stringify({
system:
'You are a helpful assistant. You are given a text in markdown format and you need to answer the question. Here is the text: ' +
editorContentFormatted,
text: prompt,
}),
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const data = (await response.json()) as string;
console.log('response', data);
setMessages((messages) => [
...messages,
{ role: 'assistant', content: data },
]);
setIsLoading(false);
};
const submit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPrompt(''); // Clear the prompt after submitting the form
await newPrompt(prompt);
};
return (
<Box
$position="absolute"
$css={`
right: 0;
bottom: 0;
padding: 1rem;
width: 450px;
min-height: min(61vh, 365px);
box-shadow: rgba(15, 15, 15, 0.04) 0px 0px 0px 1px, rgba(15, 15, 15, 0.03) 0px 3px 6px, rgba(15, 15, 15, 0.06) 0px 9px 24px;
max-height: max(-180px + 100vh, 365px);
overflow-y: auto;
border-radius: 16px;
background-color: white;
margin: 1rem;
z-index: 2;
`}
$direction="column"
>
<Box $direction="row" $align="center" $justify="space-between">
<Text $theme="greyscale" $variation="1000" $weight="bold" $size="s">
{messages.length == 0 ? '' : 'Demander à Albert'}
</Text>
<Button
size="small"
onClick={props.onClose}
color="tertiary-text"
icon={<span className="material-icons">close</span>}
/>
</Box>
{messages.length == 0 && (
<Box $gap="1rem" $position="relative" $css="top: -24px;">
<Box $gap="0.5rem">
<Box
$css={`
background-image: url('/assets/ia_baguette_question_mark.png');
background-size: cover;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
`}
></Box>
<Text $theme="primary" $variation="800">
Bonjour, comment puis-je vous aider ?
</Text>
</Box>
<Box $gap="0.5rem">
<Text $theme="greyscale" $variation="1000" $weight="bold" $size="s">
Suggestions
</Text>
<Box>
<SuggestionButton
onClick={() =>
void newPrompt(
'Resume ce document sous forme textuelle uniquement',
)
}
>
<span className="material-icons">description</span>
Résumer <span className="sub">cette page</span>
</SuggestionButton>
<SuggestionButton
onClick={() =>
void newPrompt('Quel est le sujet principal de ce document ?')
}
>
<span className="material-icons">help_center</span>
Poser des questions <span className="sub">sur cette page</span>
</SuggestionButton>
</Box>
</Box>
</Box>
)}
<Box
$flex={1}
$direction="column"
$gap="1rem"
$css={`
overflow-y: auto;
font-size: 14px;
mask-image: linear-gradient(black calc(100% - 32px), transparent calc(100% - 4px));
padding-bottom: 32px;
`}
aria-live="polite"
>
{messages.map((message, index) => (
<Message key={index} message={message} />
))}
{(isLoading || false) && (
<Box $display="flex" $direction="row" $align="center" $gap="0.5rem">
<Loader size="small" />
Albert réfléchit ...
</Box>
)}
</Box>
<Box>
<form onSubmit={(e) => void submit(e)} style={{ width: '100%' }}>
<Input
type="text"
label="Posez votre question"
name="prompt"
fullWidth={true}
onChange={(e) => setPrompt(e.target.value)}
value={prompt} // Ensure the input value is updated with the state
rightIcon={<span className="material-icons">send</span>}
/>
</form>
</Box>
</Box>
);
};
const Message = ({ message }: { message: Message }) => {
return (
<Box>
<Box $direction="row" $align="center" $gap="0.5rem">
{message.role === 'user' ? (
<Box
aria-hidden={true}
$css={`
background-color:#417DC4;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
cursor: pointer;
color: white;
font-size: 10px;
align-items: center;
justify-content: center;
display: flex;
`}
>
VD
</Box>
) : (
<Box
aria-hidden={true}
$css={`
background-image: url('/assets/ia_baguette.png');
background-size: cover;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
cursor: pointer;
`}
></Box>
)}
<Text $weight="bold">
{message.role === 'user' ? 'Vous' : 'Albert'}
</Text>
</Box>
<Box
$css={`
font-size: 12px;
padding-left: 34px;
color: var(--c--theme--colors--greyscale-700);
p {
margin: 0;
}
`}
dangerouslySetInnerHTML={{
__html: marked.parse(message.content) as string,
}}
></Box>
</Box>
);
};
@@ -1,4 +1,9 @@
import { Dictionary, locales } from '@blocknote/core';
import {
BlockNoteSchema,
Dictionary,
locales,
withPageBreak,
} from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -6,7 +11,6 @@ import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
@@ -17,95 +21,13 @@ import { useUploadFile } from '../hook';
import { useHeadings } from '../hook/useHeadings';
import useSaveDoc from '../hook/useSaveDoc';
import { useEditorStore } from '../stores';
import { cssEditor } from '../styles';
import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolbar';
const cssEditor = (readonly: boolean) => css`
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-500);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
}
.bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
}
&:has(h2) {
padding-top: 24px;
}
&:has(h3) {
padding-top: 16px;
}
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 40px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
& .bn-editor h1 {
font-size: 1.6rem;
}
& .bn-editor h2 {
font-size: 1.35rem;
}
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
}
`;
export const blockNoteSchema = withPageBreak(BlockNoteSchema.create());
interface BlockNoteEditorProps {
doc: Doc;
@@ -127,6 +49,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const collabName = readOnly
? 'Reader'
: user?.full_name || user?.email || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const editor = useCreateBlockNote(
{
@@ -138,33 +61,50 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
color: randomColor(),
},
/**
* We re-use the blocknote code to render the cursor but we:
* - fix rendering issue with Firefox
* - We don't want to show the cursor when anonymous users
* We render the cursor with a custom element to:
* - fix rendering issue with the default cursor
* - hide the cursor when anonymous users
*/
renderCursor: (user: { color: string; name: string }) => {
const cursor = document.createElement('span');
const cursorElement = document.createElement('span');
if (user.name === 'Reader') {
return cursor;
return cursorElement;
}
cursor.classList.add('collaboration-cursor__caret');
cursor.setAttribute('style', `border-color: ${user.color}`);
cursorElement.classList.add('collaboration-cursor-custom__base');
const caretElement = document.createElement('span');
caretElement.classList.add('collaboration-cursor-custom__caret');
caretElement.setAttribute('spellcheck', `false`);
caretElement.setAttribute('style', `background-color: ${user.color}`);
const label = document.createElement('span');
if (showCursorLabels === 'always') {
cursorElement.setAttribute('data-active', '');
}
label.classList.add('collaboration-cursor__label');
label.setAttribute('style', `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
const labelElement = document.createElement('span');
cursor.insertBefore(label, null);
labelElement.classList.add('collaboration-cursor-custom__label');
labelElement.setAttribute('spellcheck', `false`);
labelElement.setAttribute(
'style',
`background-color: ${user.color};border: 1px solid ${user.color};`,
);
labelElement.insertBefore(document.createTextNode(user.name), null);
return cursor;
caretElement.insertBefore(labelElement, null);
cursorElement.insertBefore(document.createTextNode('\u2060'), null); // Non-breaking space
cursorElement.insertBefore(caretElement, null);
cursorElement.insertBefore(document.createTextNode('\u2060'), null); // Non-breaking space
return cursorElement;
},
showCursorLabels: showCursorLabels as 'always' | 'activity',
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
uploadFile,
schema: blockNoteSchema,
},
[collabName, lang, provider, uploadFile],
);
@@ -197,10 +137,12 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
<BlockNoteView
editor={editor}
formattingToolbar={false}
slashMenu={false}
editable={!readOnly}
theme="light"
>
<BlockNoteToolbar />
<BlockNoteSuggestionMenu />
</BlockNoteView>
</Box>
);
@@ -225,6 +167,7 @@ export const BlockNoteEditorVersion = ({
},
provider: undefined,
},
schema: blockNoteSchema,
},
[initialContent],
);
@@ -0,0 +1,35 @@
import { combineByGroup, filterSuggestionItems } from '@blocknote/core';
import '@blocknote/mantine/style.css';
import {
SuggestionMenuController,
getDefaultReactSlashMenuItems,
getPageBreakReactSlashMenuItems,
useBlockNoteEditor,
} from '@blocknote/react';
import React, { useMemo } from 'react';
import { DocsBlockNoteEditor } from '../types';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor() as DocsBlockNoteEditor;
const getSlashMenuItems = useMemo(() => {
return async (query: string) =>
Promise.resolve(
filterSuggestionItems(
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
),
query,
),
);
}, [editor]);
return (
<SuggestionMenuController
triggerCharacter="/"
getItems={getSlashMenuItems}
/>
);
};
@@ -8,21 +8,27 @@ import {
import React, { useCallback } from 'react';
import { AIGroupButton } from './AIButton';
import { AIPdfButton } from './AIPdfButton';
import { MarkdownButton } from './MarkdownButton';
export const BlockNoteToolbar = () => {
const formattingToolbar = useCallback(
({ blockTypeSelectItems }: FormattingToolbarProps) => (
<FormattingToolbar>
{getFormattingToolbarItems(blockTypeSelectItems)}
({ blockTypeSelectItems }: FormattingToolbarProps) => {
console.log('formattingToolbar', blockTypeSelectItems);
return (
<FormattingToolbar>
{getFormattingToolbarItems(blockTypeSelectItems)}
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to do some AI powered actions */}
<AIGroupButton key="AIButton" />
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
</FormattingToolbar>
),
{/* Extra button to convert from markdown to json */}
<MarkdownButton key="customButton" />
<AIPdfButton />
</FormattingToolbar>
);
},
[],
);
@@ -16,6 +16,7 @@ import { TableContent } from '@/features/docs/doc-table-content/';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { AiButton } from './Ai/AiButton';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
interface DocEditorProps {
@@ -38,6 +39,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
return (
<>
<AiButton doc={doc} />
{isDesktop && !isVersion && (
<Box
$position="absolute"
@@ -1,9 +1,9 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useEffect } from 'react';
import { useHeadingStore } from '../stores';
import { DocsBlockNoteEditor } from '../types';
export const useHeadings = (editor: BlockNoteEditor) => {
export const useHeadings = (editor: DocsBlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {
@@ -1,9 +1,10 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { DocsBlockNoteEditor } from '../types';
export interface UseEditorstore {
editor?: BlockNoteEditor;
setEditor: (editor: BlockNoteEditor | undefined) => void;
editor?: DocsBlockNoteEditor;
setEditor: (editor: DocsBlockNoteEditor | undefined) => void;
}
export const useEditorStore = create<UseEditorstore>((set) => ({
@@ -1,7 +1,6 @@
import { BlockNoteEditor } from '@blocknote/core';
import { create } from 'zustand';
import { HeadingBlock } from '../types';
import { DocsBlockNoteEditor, HeadingBlock } from '../types';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
if (!content) {
@@ -21,7 +20,7 @@ const recursiveTextContent = (content: HeadingBlock['content']): string => {
export interface UseHeadingStore {
headings: HeadingBlock[];
setHeadings: (editor: BlockNoteEditor) => void;
setHeadings: (editor: DocsBlockNoteEditor) => void;
resetHeadings: () => void;
}
@@ -0,0 +1,121 @@
import { css } from 'styled-components';
export const cssEditor = (readonly: boolean) => css`
&,
& > .bn-container,
& .ProseMirror {
height: 100%;
.collaboration-cursor-custom__base {
position: relative;
}
.collaboration-cursor-custom__caret {
position: absolute;
height: 85%;
width: 2px;
bottom: 4%;
left: -1px;
}
.collaboration-cursor-custom__label {
color: #0d0d0d;
font-size: 12px;
font-weight: 600;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
position: absolute;
top: -17px;
padding: 0px 6px;
border-radius: 0px;
white-space: nowrap;
transition: clip-path 0.3s ease-in-out;
border-radius: 4px 4px 4px 0;
box-shadow: inset -2px 2px 6px #ffffff00;
clip-path: polygon(0 85%, 4% 85%, 4% 100%, 0% 100%);
}
.collaboration-cursor-custom__base[data-active]
.collaboration-cursor-custom__label {
pointer-events: none;
box-shadow: inset -2px 2px 6px #ffffff88;
clip-path: polygon(0 0, 100% 0%, 100% 100%, 0% 100%);
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 50px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 43px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 35px;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-500);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
}
.bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
}
&:has(h2) {
padding-top: 24px;
}
&:has(h3) {
padding-top: 16px;
}
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
@media screen and (width <= 560px) {
& .bn-editor {
${readonly && `padding-left: 10px;`}
}
.bn-side-menu[data-block-type='heading'][data-level='1'] {
height: 46px;
}
.bn-side-menu[data-block-type='heading'][data-level='2'] {
height: 40px;
}
.bn-side-menu[data-block-type='heading'][data-level='3'] {
height: 40px;
}
& .bn-editor h1 {
font-size: 1.6rem;
}
& .bn-editor h2 {
font-size: 1.35rem;
}
& .bn-editor h3 {
font-size: 1.2rem;
}
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
font-size: 14px;
}
}
`;
@@ -1,3 +1,7 @@
import { BlockNoteEditor } from '@blocknote/core';
import { blockNoteSchema } from './components/BlockNoteEditor';
export interface DocAttachment {
file: string;
}
@@ -12,3 +16,9 @@ export type HeadingBlock = {
level: number;
};
};
export type DocsBlockNoteEditor = BlockNoteEditor<
typeof blockNoteSchema.blockSchema,
typeof blockNoteSchema.inlineContentSchema,
typeof blockNoteSchema.styleSchema
>;
@@ -98,7 +98,61 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const exporter = new PDFExporter(
editor.schema,
pdfDefaultSchemaMappings,
{
...pdfDefaultSchemaMappings,
blockMapping: {
...pdfDefaultSchemaMappings.blockMapping,
heading: (block, exporter) => {
const PIXELS_PER_POINT = 0.75;
const MERGE_RATIO = 7.5;
const FONT_SIZE = 16;
const fontSizeEM =
block.props.level === 1
? 2
: block.props.level === 2
? 1.5
: 1.17;
return (
<Text
style={{
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontWeight: 700,
marginTop: `${fontSizeEM * MERGE_RATIO}px`,
marginBottom: `${fontSizeEM * MERGE_RATIO}px`,
}}
>
{exporter.transformInlineContent(block.content)}
</Text>
);
},
paragraph: (block, exporter) => {
/**
* Breakline in the editor are not rendered in the PDF
* By adding a space if the block is empty we ensure that the block is rendered
*/
if (Array.isArray(block.content)) {
block.content.forEach((content) => {
if (content.type === 'text' && !content.text) {
content.text = ' ';
}
});
if (!block.content.length) {
block.content.push({
styles: {},
text: ' ',
type: 'text',
});
}
}
return (
<Text key={block.id}>
{exporter.transformInlineContent(block.content)}
</Text>
);
},
},
},
{
resolveFileUrl: async (url) =>
exportResolveFileUrl(url, defaultExporter.options.resolveFileUrl),
@@ -1,8 +1,8 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useState } from 'react';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsBlockNoteEditor } from '@/features/docs/doc-editor';
import { useResponsiveStore } from '@/stores';
const leftPaddingMap: { [key: number]: string } = {
@@ -17,7 +17,7 @@ export type HeadingsHighlight = {
}[];
interface HeadingProps {
editor: BlockNoteEditor;
editor: DocsBlockNoteEditor;
level: number;
text: string;
headingId: string;
@@ -0,0 +1,26 @@
<svg
width="24"
height="25"
viewBox="0 0 24 25"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_7060_4428)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0.0153809C5.37 0.0153809 0 5.38538 0 12.0154C0 17.3254 3.435 21.8104 8.205 23.4004C8.805 23.5054 9.03 23.1454 9.03 22.8304C9.03 22.5454 9.015 21.6004 9.015 20.5954C6 21.1504 5.22 19.8604 4.98 19.1854C4.845 18.8404 4.26 17.7754 3.75 17.4904C3.33 17.2654 2.73 16.7104 3.735 16.6954C4.68 16.6804 5.355 17.5654 5.58 17.9254C6.66 19.7404 8.385 19.2304 9.075 18.9154C9.18 18.1354 9.495 17.6104 9.84 17.3104C7.17 17.0104 4.38 15.9754 4.38 11.3854C4.38 10.0804 4.845 9.00038 5.61 8.16038C5.49 7.86038 5.07 6.63038 5.73 4.98038C5.73 4.98038 6.735 4.66538 9.03 6.21038C9.99 5.94038 11.01 5.80538 12.03 5.80538C13.05 5.80538 14.07 5.94038 15.03 6.21038C17.325 4.65038 18.33 4.98038 18.33 4.98038C18.99 6.63038 18.57 7.86038 18.45 8.16038C19.215 9.00038 19.68 10.0654 19.68 11.3854C19.68 15.9904 16.875 17.0104 14.205 17.3104C14.64 17.6854 15.015 18.4054 15.015 19.5304C15.015 21.1354 15 22.4254 15 22.8304C15 23.1454 15.225 23.5204 15.825 23.4004C20.565 21.8104 24 17.3104 24 12.0154C24 5.38538 18.63 0.0153809 12 0.0153809Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_7060_4428">
<rect
width="24"
height="24"
fill="white"
transform="translate(0 0.0153809)"
/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,3 +1,4 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import { Trans, useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -10,6 +11,7 @@ import { Title } from '@/features/header';
import { useResponsiveStore } from '@/stores';
import SC5 from '../assets/SC5.png';
import GithubIcon from '../assets/github.svg';
import { HomeSection } from './HomeSection';
@@ -72,7 +74,7 @@ function HomeOpenSource() {
</Text>
<Text as="p" $display="inline">
<Trans t={t} i18nKey="home-content-open-source-part2">
You can easily self-host Docs (check our installation{' '}
You can easily self-hosted Docs (check our installation{' '}
<a
href="https://github.com/suitenumerique/docs/tree/main/docs"
target="_blank"
@@ -114,6 +116,27 @@ function HomeOpenSource() {
are interested in using or contributing to docs.
</Trans>
</Text>
<Box $direction="row" $gap="1rem" $margin={{ top: 'small' }}>
<Button
icon={
<Text $isMaterialIcon $color="white">
chat
</Text>
}
href="https://matrix.to/#/#docs-official:matrix.org"
target="_blank"
>
<Text $color="white">Matrix</Text>
</Button>
<Button
color="secondary"
icon={<GithubIcon />}
href="https://github.com/suitenumerique/docs"
target="_blank"
>
Github
</Button>
</Box>
</Box>
}
/>
@@ -167,6 +167,7 @@
"translation": {
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" est déjà invité à accéder au document.",
"\"{{email}}\" is already member of the document.": "\"{{email}}\" est déjà membre du document.",
"A new way to organize knowledge.": "Une nouvelle façon dorganiser les connaissances.",
"AI Actions": "Actions IA",
"AI seems busy! Please try again.": "L'IA semble occupée ! Veuillez réessayer.",
"Accessibility": "Accessibilité",
@@ -177,16 +178,22 @@
"Address:": "Adresse :",
"Administrator": "Administrateur",
"All docs": "Tous les documents",
"An uncompromising writing experience.": "Une expérience d'écriture sans compromis.",
"Anonymous": "Anonyme",
"Anyone with the link can edit the document": "N'importe qui avec le lien peut éditer le document",
"Anyone with the link can edit the document if they are logged in": "N'importe qui avec le lien peut éditer le document à condition qu'il soit connecté",
"Anyone with the link can see the document": "N'importe qui avec le lien peut voir le document",
"Anyone with the link can view the document if they are logged in": "N'importe qui avec le lien peut voir le document à condition qu'il soit connecté",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}}\" ?",
"Available soon": "Disponible prochainement",
"Back to home page": "Retour à l'accueil",
"Banner image": "Image de la bannière",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Close the modal": "Fermer la modale",
"Collaborate": "Collaborer",
"Collaborate and write in real time, without layout constraints.": "Collaborez et rédigez en temps réel, sans contrainte de mise en page.",
"Collaborative writing, Simplified.": "L'écriture collaborative simplifiée.",
"Compliance status": "État de conformité",
"Confirm deletion": "Confirmer la suppression",
"Connected": "Connecté",
@@ -206,6 +213,10 @@
"Doc visibility card": "Carte de visibilité du doc",
"Docs": "Docs",
"Docs Logo": "Logo Docs",
"Docs is already available, log in to use it now.": "Docs est déjà disponible, connectez-vous pour lutiliser dès maintenant.",
"Docs makes real-time collaboration simple. Invite collaborators - public officials or external partners - with one click to see their changes live, while maintaining precise access control for data security.": "Docs simplifie la collaboration en temps réel. Invitez des collaborateurs - agents publics ou partenaires externes - d'un clic pour voir leurs modifications en direct, tout en gardant un contrôle précis des accès pour la sécurité des données.",
"Docs offers an intuitive writing experience. Its minimalist interface favors content over layout, while offering the essentials: media import, offline mode and keyboard shortcuts for greater efficiency.": "Docs propose une expérience d'écriture intuitive. Son interface minimaliste privilégie le contenu sur la mise en page, tout en offrant l'essentiel : import de médias, mode hors-ligne et raccourcis clavier pour plus d'efficacité.",
"Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs transforme vos documents en bases de connaissances grâce aux sous-pages, une recherche performante et la possibilité d'épingler vos documents importants.",
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs : Votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement et en toute sécurité.",
"Document owner": "Propriétaire du document",
"Document title updated successfully": "Titre du document mis à jour avec succès",
@@ -227,11 +238,14 @@
"Failed to copy link": "Échec de la copie du lien",
"Failed to copy to clipboard": "Échec de la copie dans le presse-papier",
"Failed to create the invitation for {{email}}.": "Impossible de créer l'invitation pour {{email}}.",
"Flexible export.": "Un export flexible.",
"Format": "Format",
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
"Govs ❤️ Open Source.": "Gouvs ❤️ Open Source.",
"History": "Historique",
"If a member is editing, his works can be lost.": "Si un membre est en train d'éditer, ses travaux peuvent être perdus.",
"If you are unable to access a content or a service, you can contact the person responsible for https://lasuite.numerique.gouv.fr to be directed to an accessible alternative or to obtain the content in another form.": "Si vous ne pouvez pas accéder à un contenu ou à un service, vous pouvez contacter la personne responsable de https://lasuite. umerique.gouv.fr pour être dirigé vers une alternative accessible ou pour obtenir le contenu sous une autre forme.",
"Illustration": "Image",
"Illustration:": "Illustration :",
"Improvement and contact": "Amélioration et contact",
"Invite": "Inviter",
@@ -264,8 +278,10 @@
"OK": "OK",
"Offline ?!": "Hors-ligne ?!",
"Only invited people can access": "Seules les personnes invitées peuvent accéder",
"Open Source": "Open Source",
"Open the document options": "Ouvrir les options du document",
"Open the header menu": "Ouvrir le menu d'en-tête",
"Organize": "Organiser",
"Ouch !": "Aïe !",
"Owner": "Propriétaire",
"PDF": "PDF",
@@ -275,13 +291,15 @@
"Pin document icon": "Icône épingler un document",
"Pinned documents": "Documents épinglés",
"Private": "Privé",
"ProConnect Image": "Image ProConnect",
"Proconnect Login": "Login Proconnect",
"Public": "Public",
"Public document": "Document public",
"Publication Director": "Directeur de la publication",
"Publisher": "Éditeur",
"Quick search input": "Saisie de recherche rapide",
"Reader": "Lecteur",
"Reading": "Lecture seul",
"Reading": "Lecture seule",
"Remedies": "Voie de recours",
"Remove": "Supprimer",
"Rename": "Renommer",
@@ -303,8 +321,11 @@
"Shared with {{count}} users_many": "Partager avec {{count}} utilisateurs",
"Shared with {{count}} users_one": "Partager avec {{count}} utilisateur",
"Shared with {{count}} users_other": "Partager avec {{count}} utilisateurs",
"Show more": "Voir plus",
"Simple and secure collaboration.": "Une collaboration simple et sécurisée.",
"Simple document icon": "Icône simple du document",
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
"Start Writing": "Commencer à écrire",
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Directrice numérique interministériel (DINUM).",
"Summarize": "Résumer",
"Summary": "Sommaire",
@@ -319,6 +340,7 @@
"This site does not display a cookie consent banner, why?": "Ce site n'affiche pas de bannière de consentement des cookies, pourquoi?",
"This site places a small text file (a \"cookie\") on your computer when you visit it.": "Ce site place un petit fichier texte (un « cookie ») sur votre ordinateur lorsque vous le visitez.",
"This will protect your privacy, but will also prevent the owner from learning from your actions and creating a better experience for you and other users.": "Cela protégera votre vie privée, mais empêchera également le propriétaire d'apprendre de vos actions et de créer une meilleure expérience pour vous et les autres utilisateurs.",
"To facilitate the circulation of documents, Docs allows you to export your content to the most common formats: PDF, Word or OpenDocument.": "Pour faciliter la circulation des documents, Docs permet d'exporter vos contenus vers les formats les plus courants : PDF, Word ou OpenDocument.",
"Too many requests. Please wait 60 seconds.": "Trop de demandes. Veuillez patienter 60 secondes.",
"Type a name or email": "Tapez un nom ou un email",
"Type the name of a document": "Tapez le nom d'un document",
@@ -334,6 +356,7 @@
"Warning": "Attention",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure daudience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
"We try to respond within 2 working days.": "Nous essayons de répondre dans les 2 jours ouvrables.",
"Write": "Écrire",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.",
"You can oppose the tracking of your browsing on this website.": "Vous pouvez vous opposer au suivi de votre navigation sur ce site.",
"You can:": "Vous pouvez:",
@@ -345,6 +368,9 @@
"accessibility-dinum-services": "<strong>DINUM</strong> s'engage à rendre accessibles ses services numériques, conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.",
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
"accessibility-not-audit": "<strong>docs.numerique.gouv.fr</strong> n'est pas en conformité avec le RGAA 4.1. Le site n'a <strong>pas encore été audité.</strong>",
"home-content-open-source-part1": "Docs est construit sur <2>Django Rest Framework</2>, <5>Next.js</5>, et <8>MinIO</8>. Nous utilisons également <11>Yjs</11> et <15>BlockNote.js</15> dont nous sommes fiers sponsors.",
"home-content-open-source-part2": "Vous pouvez facilement auto-héberger Docs (consultez notre <2>documentation</2> d'installation avec des exemples prêts pour la production).<br/>Docs utilise une <8>licence</8> adaptée à l'innovation et aux entreprises.<br/>Les contributions sont les bienvenues (consultez notre feuille de route <13>ici</13>).",
"home-content-open-source-part3": "Docs est le résultat d'un effort conjoint mené par les gouvernements français 🇫🇷🥖 <1>(DINUM)</1> et allemand 🇩🇪🥨 <5>(ZenDiS)</5>. Nous sommes toujours à la recherche de nouveaux partenaires publics (nous embarquons actuellement les Pays-Bas 🇳🇱🧀). N'hésitez pas à nous contacter si vous souhaitez utiliser ou contribuer à Docs.",
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante."
}
},
+25849
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -28,10 +28,10 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@types/node": "22.12.0",
"@types/node": "22.13.1",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.22.0",
"@typescript-eslint/parser": "8.22.0",
"@typescript-eslint/eslint-plugin": "8.23.0",
"@typescript-eslint/parser": "8.23.0",
"eslint": "8.57.0",
"react": "18.3.1",
"react-dom": "18.3.1",
@@ -7,7 +7,7 @@
},
"dependencies": {
"@next/eslint-plugin-next": "15.1.6",
"@tanstack/eslint-plugin-query": "5.65.0",
"@tanstack/eslint-plugin-query": "5.66.0",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
+5 -5
View File
@@ -16,10 +16,10 @@
"node": ">=18"
},
"dependencies": {
"@blocknote/server-util": "0.21.0",
"@hocuspocus/server": "2.15.1",
"@sentry/node": "8.52.0",
"@sentry/profiling-node": "8.52.0",
"@blocknote/server-util": "0.23.2",
"@hocuspocus/server": "2.15.2",
"@sentry/node": "8.54.0",
"@sentry/profiling-node": "8.54.0",
"cors": "2.8.5",
"express": "4.21.2",
"express-ws": "5.0.2",
@@ -27,7 +27,7 @@
"yjs": "13.6.23"
},
"devDependencies": {
"@hocuspocus/provider": "2.15.1",
"@hocuspocus/provider": "2.15.2",
"@types/cors": "2.8.17",
"@types/express": "5.0.0",
"@types/express-ws": "3.0.5",
+14650 -14611
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 2.1.0
version: 2.1.0-beta-1
appVersion: latest
+3
View File
@@ -131,6 +131,7 @@
| `backend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
| `backend.extraVolumeMounts` | Additional volumes to mount on the backend. | `[]` |
| `backend.extraVolumes` | Additional volumes to mount on the backend. | `[]` |
| `backend.pdb.enabled` | Enable pdb on backend | `true` |
### frontend
@@ -180,6 +181,7 @@
| `frontend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
| `frontend.extraVolumeMounts` | Additional volumes to mount on the frontend. | `[]` |
| `frontend.extraVolumes` | Additional volumes to mount on the frontend. | `[]` |
| `frontend.pdb.enabled` | Enable pdb on frontend | `true` |
### posthog
@@ -261,3 +263,4 @@
| `yProvider.persistence.volume-name.mountPath` | Path where the volume should be mounted to | |
| `yProvider.extraVolumeMounts` | Additional volumes to mount on the yProvider. | `[]` |
| `yProvider.extraVolumes` | Additional volumes to mount on the yProvider. | `[]` |
| `yProvider.pdb.enabled` | Enable pdb on yProvider | `true` |
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
docker image ls | grep readme-generator-for-helm
if [ "$?" -ne "0" ]; then
@@ -138,3 +138,16 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.backend.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
@@ -138,3 +138,16 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.frontend.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
@@ -138,3 +138,16 @@ spec:
emptyDir: {}
{{- end }}
{{- end }}
---
{{ if .Values.yProvider.pdb.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
spec:
maxUnavailable: 1
selector:
matchLabels:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }}
{{ end }}
+11
View File
@@ -308,6 +308,9 @@ backend:
## @param backend.extraVolumes Additional volumes to mount on the backend.
extraVolumes: []
## @param backend.pdb.enabled Enable pdb on backend
pdb:
enabled: true
## @section frontend
@@ -403,6 +406,10 @@ frontend:
## @param frontend.extraVolumes Additional volumes to mount on the frontend.
extraVolumes: []
## @param frontend.pdb.enabled Enable pdb on frontend
pdb:
enabled: true
## @section posthog
posthog:
@@ -571,3 +578,7 @@ yProvider:
## @param yProvider.extraVolumes Additional volumes to mount on the yProvider.
extraVolumes: []
## @param yProvider.pdb.enabled Enable pdb on yProvider
pdb:
enabled: true