Compare commits

...

15 Commits

Author SHA1 Message Date
Anthony LC 12cc79b640 🔖(minor) release 4.1.0
Added:
- ️(frontend) export html

Changed:
- (frontend) improve accessibility:
  - (frontend) add skip to content button for keyboard
    accessibility
  - (frontend) fix toggle panel button a11y labels
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase
- ️(frontend) improve Comments feature

Fixed:
- 🐛(nginx) fix / location to handle new static pages
2025-12-10 11:50:14 +01:00
Anthony LC af15e77713 🐛(frontend) keep editor mounted when resize window
When resizing the window and crossing the desktop
breakpoint, the editor was unmounted. It could
lead to loss of data if there were unsaved changes,
and tiptap crash if the toolbar was used while the
editor was unmounted.
It was caused by the ResizableLeftPanel component
which was rerendering the editor.
We now keep the editor mounted when resizing
the window, by keeping the ResizableLeftPanel
component rendered but setting its size to 0
and disabling the resize handle.
2025-12-10 11:50:14 +01:00
Anthony LC 99131dc917 🐛(frontend) check tiptap editor in dom
When zooming in and out quickly, the editor
instance may not be fully mounted, leading to
errors when accessing its document. This commit
adds checks to ensure the editor and its view
are mounted before attempting to access the
document, preventing potential runtime errors.
2025-12-10 11:50:14 +01:00
AntoLC 90651a8ea6 🌐(i18n) update translated strings
Update translated files with new translations
2025-12-09 12:20:34 +01:00
Anthony LC 9c575e397c 🔒️(frontend) remove dangerouslySetInnerHTML from codebase
dangerouslySetInnerHTML were introduced to quickly
render translated strings containing HTML,
but they can lead to security vulnerabilities
if not handled properly.
Better to use React components to ensure safety.
2025-12-09 11:34:25 +01:00
Anthony LC a6b472aa51 ️(frontend) improve Comments feature
Improve the comments feature to reduce annoyance:
- gives focus on input when opening comment threads
- hide comment button when mobile view
- improve contrast of overline commented text
- remove thread if last comment deleted
- scroll to bottom thread when adding new comment
2025-12-09 11:17:42 +01:00
Anthony LC 9fcc221b33 💄(export) add style file to html export
We now include a CSS style file in the exported
ZIP archive. This file contains styles that
enhance the appearance of the exported HTML
document when viewed in a web browser to look
more like the original document.
2025-12-09 10:52:36 +01:00
Cyril acdde81a3d (frontend) fix toggle panel button a11y with dynamic label
improves screen sr by updating label and state indication dynamically

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:57:12 +01:00
Cyril 9b03754f88 (e2e) add test for accessible html export from export modal
checks generated zip contains html and embedded media files

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:24:14 +01:00
Cyril 0805216cc6 (frontend) added accessible html export and moved download option
replaced “copy as html” with export modal option and full media zip export

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:24:10 +01:00
Cyril 5e398e8e79 (frontend) move html option to downloads section
makes the option less visible as it's not useful to most users

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-09 09:22:30 +01:00
renovate[bot] 00ae7fdd60 ⬆️(dependencies) update django to v5.2.9 [SECURITY]
upgrade to django 5.2.9
2025-12-08 14:36:21 +00:00
renovate[bot] 8036f16cc3 ⬆️(dependencies) update next to v15.5.7 [SECURITY] 2025-12-03 21:49:27 +00:00
Manuel Raynaud 54fe70d662 🐛(nginx) fix / location to handle new static pages
The / location is not trying the $uri/index.html file. We should try
this instad of $uri/ because when a new static page is added, we always
have this pattern.
2025-12-03 12:14:19 +01:00
Cyril 1e37007be9 (a11y) add skip to content button for keyboard accessibility
add SkipToContent component to meet RGAA skiplink requirement

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add e2e test for skiplink and fix broken accessibility test

ensures skiplink behavior is tested and stabilizes a failing accessibility test

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-12-02 13:37:58 +01:00
65 changed files with 1610 additions and 432 deletions
+21 -1
View File
@@ -6,6 +6,25 @@ and this project adheres to
## [Unreleased]
## [4.1.0] - 2025-12-09
### Added
- ⚡️(frontend) export html #1669
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
- ♿(frontend) fix toggle panel button a11y labels #1634
- 🔒️(frontend) remove dangerouslySetInnerHTML from codebase #1712
- ⚡️(frontend) improve Comments feature #1687
### Fixed
- 🐛(nginx) fix / location to handle new static pages #1682
- 🐛(frontend) rerendering during resize window #1715
## [4.0.0] - 2025-12-01
### Added
@@ -900,7 +919,8 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.0.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v4.1.0...main
[v4.1.0]: https://github.com/suitenumerique/docs/releases/v4.1.0
[v4.0.0]: https://github.com/suitenumerique/docs/releases/v4.0.0
[v3.10.0]: https://github.com/suitenumerique/docs/releases/v3.10.0
[v3.9.0]: https://github.com/suitenumerique/docs/releases/v3.9.0
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Breton\n"
"Language: br_FR\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\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-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\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-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Language: es_ES\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\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-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Language: it_IT\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Portuguese\n"
"Language: pt_PT\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Slovenian\n"
"Language: sl_SI\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Swedish\n"
"Language: sv_SE\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Ukrainian\n"
"Language: uk_UA\n"
@@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-20 14:08+0000\n"
"PO-Revision-Date: 2025-11-26 13:33\n"
"PO-Revision-Date: 2025-12-09 11:12\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Language: zh_CN\n"
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "4.0.0"
version = "4.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -39,7 +39,7 @@ dependencies = [
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.2.8",
"django==5.2.9",
"django-treebeard==4.7.1",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
@@ -1,6 +1,11 @@
import { expect, test } from '@playwright/test';
import { createDoc, getOtherBrowserName, verifyDocName } from './utils-common';
import {
closeHeaderMenu,
createDoc,
getOtherBrowserName,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import {
addNewMember,
@@ -116,8 +121,7 @@ test.describe('Doc Comments', () => {
await createDoc(page, 'comment-interaction', browserName, 1);
// Checks add react reaction
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('Hello World');
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
@@ -181,6 +185,28 @@ test.describe('Doc Comments', () => {
'background-color',
'rgba(0, 0, 0, 0)',
);
/* Delete the last comment remove the thread */
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment' }).click();
await thread.getByRole('paragraph').first().fill('This is a new comment');
await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(237, 180, 0, 0.4)',
);
await editor.getByText('Hello').click();
await thread.getByText('This is a new comment').first().hover();
await thread.locator('[data-test="moreactions"]').first().click();
await thread.getByRole('menuitem', { name: 'Delete comment' }).click();
await expect(editor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
});
test('it checks the comments abilities', async ({ page, browserName }) => {
@@ -293,3 +319,27 @@ test.describe('Doc Comments', () => {
await cleanup();
});
});
test.describe('Doc Comments mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test('Comments are not visible on mobile', async ({ page, browserName }) => {
const [title] = await createDoc(
page,
'comment-mobile',
browserName,
1,
true,
);
await closeHeaderMenu(page);
await verifyDocName(page, title);
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await expect(page.getByRole('button', { name: 'Comment' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeVisible();
});
});
@@ -996,4 +996,44 @@ test.describe('Doc Editor', () => {
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
});
test('it preserves text when switching between mobile and desktop views', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-viewport-test',
browserName,
1,
);
await verifyDocName(page, docTitle);
const editor = await writeInEditor({
page,
text: 'Hello World - Desktop Text',
});
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await page.waitForTimeout(500);
// Switch to mobile viewport
await page.setViewportSize({ width: 500, height: 1200 });
await page.waitForTimeout(500);
await expect(editor.getByText('Hello World - Desktop Text')).toBeVisible();
await writeInEditor({
page,
text: 'Mobile Text',
});
await page.waitForTimeout(500);
// Switch back to desktop viewport
await page.setViewportSize({ width: 1280, height: 720 });
await page.waitForTimeout(500);
await expect(editor.getByText('Mobile Text')).toBeVisible();
});
});
@@ -2,6 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import JSZip from 'jszip';
import { PDFParse } from 'pdf-parse';
import {
@@ -31,7 +32,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText('Download your document in a .docx, .odt or .pdf format.'),
page.getByText(/Download your document in a \.docx, \.odt.*format\./i),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
@@ -187,6 +188,89 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
test('it exports the doc to html zip', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-html-zip',
browserName,
1,
);
await verifyDocName(page, randomDoc);
// Add some content and at least one image so that the ZIP contains media files.
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello HTML ZIP');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
// Wait for the image to be attached and have a valid src (aria-hidden prevents toBeVisible on Chromium)
await expect(image).toBeAttached({ timeout: 10000 });
await expect(image).toHaveAttribute('src', /.*\.svg/);
// Give some time for the image to be fully processed
await page.waitForTimeout(1000);
await page
.getByRole('button', {
name: 'Export the document',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'HTML' }).click();
await expect(page.getByTestId('doc-export-download-button')).toBeVisible();
const downloadPromise = page.waitForEvent('download', (download) => {
return download.suggestedFilename().includes(`${randomDoc}.zip`);
});
void page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`);
const zipBuffer = await cs.toBuffer(await download.createReadStream());
// Unzip and inspect contents
const zip = await JSZip.loadAsync(zipBuffer);
// Check that index.html exists
const indexHtml = zip.file('index.html');
expect(indexHtml).not.toBeNull();
// Read and verify HTML content
const htmlContent = await indexHtml!.async('string');
expect(htmlContent).toContain('Hello HTML ZIP');
expect(htmlContent).toContain('href="styles.css"');
// Check for media files (they are at the root of the ZIP, not in a media/ folder)
// Media files are named like "1-test.svg" or "media-1.png" by deriveMediaFilename
const allFiles = Object.keys(zip.files);
const mediaFiles = allFiles.filter(
(name) => name !== 'index.html' && !name.endsWith('/'),
);
expect(mediaFiles.length).toBeGreaterThan(0);
// Verify the SVG image is included
const svgFile = mediaFiles.find((name) => name.endsWith('.svg'));
expect(svgFile).toBeDefined();
const styleFile = mediaFiles.find((name) => name === 'styles.css');
expect(styleFile).toBeDefined();
});
/**
* This test tell us that the export to pdf is working with images
* but it does not tell us if the images are being displayed correctly
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs } from './utils-common';
import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common';
import { createRootSubPage } from './utils-sub-pages';
test.describe('Doc grid dnd', () => {
@@ -185,10 +185,7 @@ test.describe('Doc grid dnd mobile', () => {
true,
);
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await toggleHeaderMenu(page);
await expect(page.locator('.--docs-sub-page-item').first()).toHaveAttribute(
'draggable',
@@ -408,40 +408,6 @@ test.describe('Doc Header', () => {
expect(clipboardContent.trim()).toBe('# Hello World');
});
test('It checks the copy as HTML button', async ({ page, browserName }) => {
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
})
.click();
// Add dummy content to the doc
const editor = page.locator('.ProseMirror');
const docFirstBlock = editor.locator('.bn-block-content').first();
await docFirstBlock.click();
await page.keyboard.type('# Hello World', { delay: 100 });
const docFirstBlockContent = docFirstBlock.locator('h1');
await expect(docFirstBlockContent).toHaveText('Hello World');
// Copy content to clipboard
await page.getByLabel('Open the document options').click();
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
await expect(page.getByText('Copied to clipboard')).toBeVisible();
// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
});
test('it checks the copy link button', async ({ page, browserName }) => {
test.skip(
browserName === 'webkit',
@@ -177,3 +177,27 @@ test.describe('Header: Override configuration', () => {
await expect(logoImage).toHaveAttribute('alt', '');
});
});
test.describe('Header: Skip to Content', () => {
test('it displays skip link on first TAB and focuses main content on click', async ({
page,
}) => {
await page.goto('/');
// Wait for skip button to be mounted (client-side only component)
const skipButton = page.getByRole('button', { name: 'Go to content' });
await skipButton.waitFor({ state: 'attached' });
// First TAB shows the skip button
await page.keyboard.press('Tab');
// The skip button should be visible and focused
await expect(skipButton).toBeFocused();
await expect(skipButton).toBeVisible();
// Clicking moves focus to the main content
await skipButton.click();
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
});
});
@@ -66,6 +66,7 @@ test.describe('Language', () => {
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');
@@ -83,6 +83,34 @@ export const randomName = (name: string, browserName: string, length: number) =>
return `${browserName}-${Math.floor(Math.random() * 10000)}-${index}-${name}`;
});
export const openHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
const isExpanded =
(await toggleButton.getAttribute('aria-expanded')) === 'true';
if (!isExpanded) {
await toggleButton.click();
}
};
export const closeHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
const isExpanded =
(await toggleButton.getAttribute('aria-expanded')) === 'true';
if (isExpanded) {
await toggleButton.click();
}
};
export const toggleHeaderMenu = async (page: Page) => {
const toggleButton = page.getByTestId('header-menu-toggle');
await expect(toggleButton).toBeVisible();
await toggleButton.click();
};
export const createDoc = async (
page: Page,
docName: string,
@@ -94,10 +122,7 @@ export const createDoc = async (
for (let i = 0; i < randomDocs.length; i++) {
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await openHeaderMenu(page);
}
await page
@@ -2,6 +2,8 @@ import { Page, expect } from '@playwright/test';
import {
BrowserName,
closeHeaderMenu,
openHeaderMenu,
randomName,
updateDocTitle,
verifyDocName,
@@ -15,10 +17,7 @@ export const createRootSubPage = async (
isMobile = false,
) => {
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await openHeaderMenu(page);
}
// Get response
@@ -29,10 +28,7 @@ export const createRootSubPage = async (
const subPageJson = (await response.json()) as { id: string };
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('menu')
.click();
await openHeaderMenu(page);
}
// Get doc tree
@@ -44,13 +40,9 @@ export const createRootSubPage = async (
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
if (isMobile) {
await page
.getByRole('button', { name: 'Open the header menu' })
.getByText('close')
.click();
await closeHeaderMenu(page);
}
// Update sub page name
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "4.0.0",
"version": "4.1.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+1 -1
View File
@@ -6,7 +6,7 @@ server {
root /usr/share/nginx/html;
location / {
try_files $uri index.html $uri/ =404;
try_files $uri index.html $uri/index.html =404;
add_header X-Frame-Options DENY always;
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "4.0.0",
"version": "4.1.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -57,7 +57,7 @@
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.7.2",
"next": "15.5.4",
"next": "15.5.7",
"posthog-js": "1.298.0",
"react": "*",
"react-aria-components": "1.13.0",
@@ -0,0 +1,78 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
export const SkipToContent = () => {
const { t } = useTranslation();
const router = useRouter();
const { spacingsTokens } = useCunninghamTheme();
const [isVisible, setIsVisible] = useState(false);
// Reset focus after route change so first TAB goes to skip link
useEffect(() => {
const handleRouteChange = () => {
(document.activeElement as HTMLElement)?.blur();
document.body.setAttribute('tabindex', '-1');
document.body.focus({ preventScroll: true });
setTimeout(() => {
document.body.removeAttribute('tabindex');
}, 100);
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events]);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
mainContent.focus();
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<Box
$css={css`
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
box-shadow:
0 0 0 1px var(--c--globals--colors--white-000),
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Button
onClick={handleClick}
type="button"
color="brand"
className="--docs--skip-to-content"
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
style={{
opacity: isVisible ? 1 : 0,
pointerEvents: isVisible ? 'auto' : 'none',
position: 'fixed',
top: spacingsTokens['2xs'],
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
zIndex: 9999,
whiteSpace: 'nowrap',
}}
>
{t('Go to content')}
</Button>
</Box>
);
};
@@ -11,5 +11,6 @@ export * from './Loading';
export * from './modal';
export * from './Overlayer';
export * from './separators';
export * from './SkipToContent';
export * from './Text';
export * from './TextErrors';
@@ -87,7 +87,9 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { isDesktop } = useResponsiveStore();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const refEditorContainer = useRef<HTMLDivElement>(null);
const canSeeComment = doc.abilities.comment && isDesktop;
const canSeeComment = doc.abilities.comment;
// Determine if comments should be visible in the UI
const showComments = canSeeComment && isDesktop;
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
@@ -207,7 +209,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
ref={refEditorContainer}
$css={css`
${cssEditor};
${cssComments(canSeeComment, currentUserAvatarUrl)}
${cssComments(showComments, currentUserAvatarUrl)}
`}
>
{errorAttachment && (
@@ -225,7 +227,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
formattingToolbar={false}
slashMenu={false}
theme="light"
comments={canSeeComment}
comments={showComments}
aria-label={t('Document editor')}
>
<BlockNoteSuggestionMenu />
@@ -1,3 +1,8 @@
/**
* This file is adapted from BlockNote's AddCommentButton component
* https://github.com/TypeCellOS/BlockNote/blob/main/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx
*/
import {
useBlockNoteEditor,
useComponentsContext,
@@ -10,6 +15,7 @@ import { css } from 'styled-components';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDocStore } from '@/features/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import {
DocsBlockSchema,
@@ -22,6 +28,7 @@ export const CommentToolbarButton = () => {
const { currentDoc } = useDocStore();
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const editor = useBlockNoteEditor<
DocsBlockSchema,
@@ -35,7 +42,18 @@ export const CommentToolbarButton = () => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
const focusOnInputThread = () => {
// Use setTimeout to ensure the DOM has been updated with the new comment
setTimeout(() => {
const threadElement = document.querySelector<HTMLElement>(
'.bn-thread .bn-editor',
);
threadElement?.focus();
}, 400);
};
if (
!isDesktop ||
!show ||
!editor.isEditable ||
!Components ||
@@ -51,6 +69,7 @@ export const CommentToolbarButton = () => {
onClick={() => {
editor.comments?.startPendingComment();
editor.formattingToolbar.closeMenu();
focusOnInputThread();
}}
aria-haspopup="dialog"
data-test="comment-toolbar-button"
@@ -117,6 +117,21 @@ export class DocsThreadStore extends ThreadStore {
});
}
/**
* Scrolls to the bottom of a thread modal
* @param threadId
*/
private scrollToBottomOfThread() {
// Use setTimeout to ensure the DOM has been updated with the new comment
setTimeout(() => {
const threadElement = document.querySelector('.bn-thread');
threadElement?.scrollBy({
top: threadElement.scrollHeight,
behavior: 'smooth',
});
}, 200);
}
/**
* Notifies all subscribers about the current thread state
*/
@@ -345,6 +360,10 @@ export class DocsThreadStore extends ThreadStore {
await this.refreshThread(threadId);
}
this.ping(threadId);
// Auto-scroll to bottom of thread after adding comment
this.scrollToBottomOfThread();
return serverCommentToClientComment(comment);
};
@@ -405,10 +424,20 @@ export class DocsThreadStore extends ThreadStore {
// Optimistically remove the comment locally if we have the thread
const existing = this.threads.get(threadId);
if (existing) {
const updatedComments = existing.comments.filter(
(c) => c.id !== commentId,
);
// If this was the last comment, delete the thread
if (updatedComments.length === 0) {
await this.deleteThread({ threadId });
return;
}
const updated: ClientThreadData = {
...existing,
updatedAt: new Date(),
comments: existing.comments.filter((c) => c.id !== commentId),
comments: updatedComments,
};
this.upsertClientThreadData(updated);
this.notifySubscribers();
@@ -419,10 +448,6 @@ export class DocsThreadStore extends ThreadStore {
this.ping(threadId);
};
/**
* UI not implemented
* @param _options
*/
public deleteThread = async (_options: { threadId: string }) => {
const response = await fetchAPI(
`documents/${this.docId}/threads/${_options.threadId}/`,
@@ -13,6 +13,10 @@ export const cssComments = (
background: ${canSeeComment ? '#EDB40066' : 'transparent'};
color: var(--c--globals--colors--gray-700);
}
[data-show-selection] {
color: HighlightText;
}
}
em-emoji-picker {
@@ -7,6 +7,11 @@ export const useHeadings = (editor: DocsBlockNoteEditor) => {
const { setHeadings, resetHeadings } = useHeadingStore();
useEffect(() => {
// Check if editor and its view are mounted before accessing document
if (!editor || !editor._tiptapEditor?.view?.dom) {
return;
}
setHeadings(editor);
let timeoutId: NodeJS.Timeout;
@@ -7,6 +7,11 @@ export const useShortcuts = (
el: HTMLDivElement | null,
) => {
useEffect(() => {
// Check if editor and its view are mounted
if (!editor || !editor._tiptapEditor?.view?.dom || !el) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === '@' && editor?.isFocused()) {
const selection = window.getSelection();
@@ -32,10 +37,6 @@ export const useShortcuts = (
}
};
if (!el) {
return;
}
el.addEventListener('keydown', handleKeyDown);
return () => {
@@ -95,6 +95,11 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
);
useEffect(() => {
// Check if editor and its view are mounted before accessing document
if (!editor || !editor._tiptapEditor?.view?.dom) {
return;
}
const imagesBlocks = editor?.document.filter(
(block) =>
block.type === 'image' && block.props.url.includes(ANALYZE_URL),
@@ -110,6 +115,11 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
* block to show analyzing status
*/
useEffect(() => {
// Check if editor and its view are mounted before setting up handlers
if (!editor || !editor._tiptapEditor?.view?.dom) {
return;
}
editor.onUploadEnd((blockId) => {
if (!blockId) {
return;
@@ -28,6 +28,11 @@ export interface UseHeadingStore {
export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
headings: [],
setHeadings: (editor) => {
// Check if editor and its view are mounted before accessing document
if (!editor || !editor._tiptapEditor?.view?.dom) {
return;
}
const headingBlocks = editor?.document
.filter(
(block) =>
@@ -16,12 +16,12 @@ describe('useModuleExport', () => {
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toBeUndefined();
}, 10000);
}, 15000);
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
expect(Export.default).toHaveProperty('ModalExport');
});
}, 15000);
});
@@ -0,0 +1,67 @@
import { deriveMediaFilename } from '../utils';
describe('deriveMediaFilename', () => {
test('uses last URL segment when src is a valid URL', () => {
const result = deriveMediaFilename({
src: 'https://example.com/path/video.mp4',
index: 0,
blob: new Blob([], { type: 'video/mp4' }),
});
expect(result).toBe('1-video.mp4');
});
test('handles URLs with query/hash and keeps the last segment', () => {
const result = deriveMediaFilename({
src: 'https://site.com/assets/file.name.svg?x=1#test',
index: 0,
blob: new Blob([], { type: 'image/svg+xml' }),
});
expect(result).toBe('1-file.name.svg');
});
test('handles relative URLs using last segment', () => {
const result = deriveMediaFilename({
src: 'not a valid url',
index: 0,
blob: new Blob([], { type: 'image/png' }),
});
// "not a valid url" becomes a relative URL, so we get the last segment
expect(result).toBe('1-not%20a%20valid%20url.png');
});
test('data URLs always use media-{index+1}', () => {
const result = deriveMediaFilename({
src: 'data:image/png;base64,xxx',
index: 0,
blob: new Blob([], { type: 'image/png' }),
});
expect(result).toBe('media-1.png');
});
test('adds extension from MIME when baseName has no extension', () => {
const result = deriveMediaFilename({
src: 'https://a.com/abc',
index: 0,
blob: new Blob([], { type: 'image/webp' }),
});
expect(result).toBe('1-abc.webp');
});
test('does not override extension if baseName already contains one', () => {
const result = deriveMediaFilename({
src: 'https://a.com/image.png',
index: 0,
blob: new Blob([], { type: 'image/jpeg' }),
});
expect(result).toBe('1-image.png');
});
test('handles complex MIME types (e.g., audio/mpeg)', () => {
const result = deriveMediaFilename({
src: 'https://a.com/song',
index: 1,
blob: new Blob([], { type: 'audio/mpeg' }),
});
expect(result).toBe('2-song.mpeg');
});
});
@@ -0,0 +1,569 @@
/* Reset and Base Styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: inter, 'roboto flex variable', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: normal;
color: #25252f;
background-color: #f9f9f9;
}
main {
max-width: 868px;
width: 100%;
margin: 0 auto;
padding: 24px 54px 32px;
background: #fff;
min-height: 100vh;
}
/* Editor Container Styles */
.--docs--editor-container {
width: 100%;
padding: 24px 0 32px;
background: #fff;
font-size: 16px;
color: #25252f;
}
/* Block Spacing */
.bn-block-outer {
margin: 0;
}
.bn-block-content {
margin-bottom: 0;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 0;
margin-bottom: 0;
line-height: 1.5;
color: #454558;
}
h1 {
font-size: 30px;
font-weight: 700;
line-height: 45px;
}
h2 {
font-size: 24px;
font-weight: 700;
line-height: 36px;
}
h3 {
font-size: 20px;
font-weight: 700;
line-height: 30px;
}
h4 {
font-size: 18px;
font-weight: 600;
line-height: 27px;
}
h5 {
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
h6 {
font-size: 14px;
font-weight: 600;
line-height: 21px;
}
p {
margin: 0;
line-height: 24px;
}
.bn-inline-content {
line-height: 24px;
}
/* Links */
a {
color: #5d5d70;
text-decoration: underline;
cursor: pointer;
}
a:hover {
color: #454558;
}
/* Text Formatting */
strong,
b {
font-weight: 700;
}
em,
i {
font-style: italic;
}
u {
text-decoration: underline;
}
s {
text-decoration: line-through;
}
/* Custom text colors */
[data-style-type='textColor'][data-value='red'] {
color: rgb(224 62 62);
}
[data-style-type='backgroundColor'][data-value='blue'] {
background-color: rgb(221 235 241);
padding: 2px 4px;
border-radius: 3px;
}
/* Lists */
.bn-block-content[data-content-type='bulletListItem'],
.bn-block-content[data-content-type='numberedListItem'] {
padding-left: 0;
margin: 0;
display: list-item;
list-style-position: outside;
}
.bn-block-content[data-content-type='bulletListItem'] {
list-style-type: disc;
margin-left: 24px;
}
.bn-block-content[data-content-type='numberedListItem'] {
list-style-type: decimal;
margin-left: 24px;
}
.bn-block-content[data-content-type='bulletListItem'] p,
.bn-block-content[data-content-type='numberedListItem'] p {
display: inline;
}
/* Checkboxes */
.bn-block-content[data-content-type='checkListItem'] {
display: flex;
align-items: center;
gap: 8px;
}
.bn-block-content[data-content-type='checkListItem'] input[type='checkbox'] {
margin: 0;
width: 16px;
height: 16px;
cursor: pointer;
flex-shrink: 0;
}
.bn-block-content[data-content-type='checkListItem'] p {
flex: 1;
margin: 0;
}
/* Quotes */
blockquote,
.bn-block-content[data-content-type='quote'] blockquote {
border-left: 4px solid #a9a9bf;
padding-left: 16px;
margin: 0;
color: #7d797a;
font-style: normal;
}
/* Code Blocks */
.bn-block-content[data-content-type='codeBlock'] {
background-color: #161616;
border-radius: 8px;
padding: 3px 0;
margin: 8px 0;
overflow: auto;
}
.bn-block-content[data-content-type='codeBlock'] pre {
background-color: transparent;
padding: 24px;
margin: 0;
border-radius: 0;
overflow-x: auto;
}
.bn-block-content[data-content-type='codeBlock'] code {
font-family: monospace;
font-size: 16px;
line-height: 24px;
color: #fff;
background: transparent;
padding: 0;
}
.bn-block-content[data-content-type='codeBlock'] select {
padding: 4px 8px;
margin: 8px 0 0 24px;
border: 1px solid #3a3a3a;
border-radius: 4px;
background-color: #2a2a2a;
color: #fff;
font-size: 12.8px;
cursor: pointer;
}
/* Inline Code */
code {
font-family: monospace;
font-size: 16px;
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
}
pre code {
background: none;
padding: 0;
}
/* Tables */
table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
}
th,
td {
border: 1px solid #ddd;
padding: 5px 10px;
text-align: left;
vertical-align: top;
}
th {
background-color: #f5f5f5;
font-weight: 700;
}
td p {
margin: 0;
}
/* Callout Blocks */
.bn-block-content[data-content-type='callout'] {
display: flex;
align-items: flex-start;
gap: 8px;
background-color: #fbf3db;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
}
.bn-block-content[data-content-type='callout'][data-background-color='yellow'] {
background-color: #fbf3db;
}
.bn-block-content[data-content-type='callout'][data-background-color='blue'] {
background-color: #ddedf1;
}
.bn-block-content[data-content-type='callout'][data-background-color='red'] {
background-color: #ffe5e5;
}
.bn-block-content[data-content-type='callout'][data-background-color='green'] {
background-color: #e5f5e5;
}
.bn-block-content[data-content-type='callout'] > div {
display: flex;
flex-direction: row;
}
.bn-block-content[data-content-type='callout'] button {
background: transparent;
border: none;
padding: 0;
cursor: default;
font-size: 20px;
line-height: 1;
flex-shrink: 0;
}
.bn-block-content[data-content-type='callout'] .inline-content {
flex: 1;
}
/* Toggle Sections */
.bn-toggle-wrapper {
display: flex;
align-items: flex-start;
gap: 4px;
}
.bn-toggle-button {
width: 24px;
height: 24px;
padding: 3px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.bn-toggle-button:hover {
background-color: #f0f0f0;
}
.bn-toggle-button svg {
width: 18px;
height: 18px;
fill: #454558;
transition: transform 0.2s;
}
.bn-toggle-wrapper[data-show-children='true'] .bn-toggle-button svg {
transform: rotate(90deg);
}
.bn-toggle-wrapper h1,
.bn-toggle-wrapper h2,
.bn-toggle-wrapper h3,
.bn-toggle-wrapper h4,
.bn-toggle-wrapper h5,
.bn-toggle-wrapper h6,
.bn-toggle-wrapper p {
flex: 1;
margin: 0;
}
/* Toggle List Items */
.bn-block-content[data-content-type='toggleListItem'] {
margin-left: 24px;
}
/* Images and Media */
img.bn-visual-media {
max-width: 100%;
height: auto;
display: block;
border-radius: 4px;
margin: 8px 0;
}
video.bn-visual-media {
width: 100% !important;
height: auto !important;
max-width: 760px;
display: block;
border-radius: 4px;
margin: 8px 0;
object-fit: contain;
background-color: #000;
}
audio.bn-audio {
width: 100%;
max-width: 760px;
margin: 8px 0;
}
/* File Blocks */
.bn-file-block-content-wrapper {
margin: 8px 0;
max-width: 100%;
}
figure.bn-file-block-content-wrapper {
margin: 8px 0;
max-width: 100%;
}
.bn-block-content[data-content-type='video'] .bn-file-block-content-wrapper {
display: flex;
flex-direction: column;
width: fit-content;
max-width: 100%;
}
.bn-visual-media-wrapper {
display: flex;
position: relative;
max-width: 100%;
}
.bn-file-caption,
figcaption.bn-file-caption {
color: #454558;
font-size: 12.8px;
margin-top: 4px;
text-align: left;
font-style: normal;
}
/* Add File Button */
.bn-add-file-button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border: 2px dashed #d0d0d0;
border-radius: 8px;
cursor: pointer;
transition:
border-color 0.2s,
background-color 0.2s;
}
.bn-add-file-button:hover {
border-color: #a0a0a0;
background-color: #f9f9f9;
}
.bn-add-file-button-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.bn-add-file-button-icon svg {
width: 100%;
height: 100%;
fill: #757575;
}
.bn-add-file-button-text {
margin: 0;
color: #757575;
font-size: 14px;
font-weight: 500;
}
/* Buttons */
button {
cursor: pointer;
font-family: inherit;
}
/* Select/Dropdown */
select {
font-family: inherit;
cursor: pointer;
}
/* Spacing between blocks */
.bn-block-outer + .bn-block-outer {
margin-top: 4px;
}
/* Heading spacing */
.bn-block-content[data-content-type='heading'] + .bn-block-outer {
margin-top: 8px;
}
.bn-block-outer + .bn-block-content[data-content-type='heading'] {
margin-top: 16px;
}
/* Responsive */
@media (width <= 920px) {
main {
padding: 24px 32px 32px;
}
}
@media (width <= 768px) {
main {
padding: 16px 16px 32px;
}
h1 {
font-size: 26px;
line-height: 39px;
}
h2 {
font-size: 22px;
line-height: 33px;
}
h3 {
font-size: 18px;
line-height: 27px;
}
video.bn-visual-media {
max-width: 100%;
}
}
/* Print styles */
@media print {
body {
background: white;
}
main {
max-width: 100%;
padding: 0;
}
button,
select,
.bn-add-file-button {
display: none;
}
a {
color: #000;
text-decoration: underline;
}
.bn-block-content[data-content-type='codeBlock'] {
background-color: #f5f5f5;
border: 1px solid #ddd;
}
.bn-block-content[data-content-type='codeBlock'] code {
color: #000;
}
}
/* Accessibility */
*:focus-visible {
outline: 2px solid #06c;
outline-offset: 2px;
}
/* Hidden elements for export */
[contenteditable='false'] {
user-select: none;
}
@@ -13,22 +13,30 @@ import {
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
import i18next from 'i18next';
import JSZip from 'jszip';
import { cloneElement, isValidElement, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { useEditorStore } from '@/docs/doc-editor';
import { Doc, useTrans } from '@/docs/doc-management';
import { fallbackLng } from '@/i18n/config';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import { downloadFile } from '../utils';
import {
addMediaFilesToZip,
downloadFile,
generateHtmlDocument,
} from '../utils';
enum DocDownloadFormat {
HTML = 'html',
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
@@ -52,6 +60,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const templateOptions = useMemo(() => {
const templateOptions = (templates?.pages || [])
@@ -142,13 +151,47 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
});
blobExport = await exporter.toODTDocument(exportDocument);
} else if (format === DocDownloadFormat.HTML) {
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
const fullHtml = await editor.blocksToFullHTML();
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
const domParser = new DOMParser();
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
const zip = new JSZip();
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
const lang = i18next.language || fallbackLng;
const editorHtmlWithLocalMedia = parsedDocument.body.innerHTML;
const htmlContent = generateHtmlDocument(
documentTitle,
editorHtmlWithLocalMedia,
lang,
);
zip.file('index.html', htmlContent);
// CSS Styles
const cssResponse = await fetch(
new URL('../assets/export-html-styles.txt', import.meta.url).toString(),
);
const cssContent = await cssResponse.text();
zip.file('styles.css', cssContent);
blobExport = await zip.generateAsync({ type: 'blob' });
} else {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
downloadFile(blobExport, `${filename}.${format}`);
const downloadExtension =
format === DocDownloadFormat.HTML ? 'zip' : format;
downloadFile(blobExport, `${filename}.${downloadExtension}`);
toast(
t('Your {{format}} was downloaded succesfully', {
@@ -225,18 +268,10 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
className="--docs--modal-export-content"
>
<Text $variation="secondary" $size="sm" as="p">
{t('Download your document in a .docx, .odt or .pdf format.')}
{t(
'Download your document in a .docx, .odt, .pdf or .html(zip) format.',
)}
</Text>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
<Select
clearable={false}
fullWidth
@@ -245,12 +280,24 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('HTML'), value: DocDownloadFormat.HTML },
]}
value={format}
onChange={(options) =>
setFormat(options.target.value as DocDownloadFormat)
}
/>
<Select
clearable={false}
fullWidth
label={t('Template')}
options={templateOptions}
value={templateSelected}
disabled={format === DocDownloadFormat.HTML}
onChange={(options) =>
setTemplateSelected(options.target.value as string)
}
/>
{isExporting && (
<Box
@@ -5,8 +5,11 @@ import {
} from '@blocknote/core';
import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import JSZip from 'jszip';
import React from 'react';
import { exportResolveFileUrl } from './api';
export function downloadFile(blob: Blob, filename: string) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -179,3 +182,172 @@ export function odtRegisterParagraphStyleForBlock(
return styleName;
}
// Escape user-provided text before injecting it into the exported HTML document.
export const escapeHtml = (value: string): string =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
interface MediaFilenameParams {
src: string;
index: number;
blob: Blob;
}
/**
* Derives a stable, readable filename for media exported in the HTML ZIP.
*
* Rules:
* - Default base name is "media-{index+1}".
* - For non data: URLs, we reuse the last path segment when possible (e.g. 1-photo.png).
* - If the base name has no extension, we try to infer one from the blob MIME type.
*/
export const deriveMediaFilename = ({
src,
index,
blob,
}: MediaFilenameParams): string => {
// Default base name
let baseName = `media-${index + 1}`;
// Try to reuse the last path segment for non data URLs.
if (!src.startsWith('data:')) {
try {
const url = new URL(src, window.location.origin);
const lastSegment = url.pathname.split('/').pop();
if (lastSegment) {
baseName = `${index + 1}-${lastSegment}`;
}
} catch {
// Ignore invalid URLs, keep default baseName.
}
}
let filename = baseName;
// Ensure the filename has an extension consistent with the blob MIME type.
const mimeType = blob.type;
if (mimeType && !baseName.includes('.')) {
const slashIndex = mimeType.indexOf('/');
const rawSubtype =
slashIndex !== -1 && slashIndex < mimeType.length - 1
? mimeType.slice(slashIndex + 1)
: '';
let extension = '';
const subtype = rawSubtype.toLowerCase();
if (subtype.includes('svg')) {
extension = 'svg';
} else if (subtype.includes('jpeg') || subtype.includes('pjpeg')) {
extension = 'jpg';
} else if (subtype.includes('png')) {
extension = 'png';
} else if (subtype.includes('gif')) {
extension = 'gif';
} else if (subtype.includes('webp')) {
extension = 'webp';
} else if (subtype.includes('pdf')) {
extension = 'pdf';
} else if (subtype) {
extension = subtype.split('+')[0];
}
if (extension) {
filename = `${baseName}.${extension}`;
}
}
return filename;
};
/**
* Generates a complete HTML document structure for export.
*
* @param documentTitle - The title of the document (will be escaped)
* @param editorHtmlWithLocalMedia - The HTML content from the editor
* @param lang - The language code for the document (e.g., 'fr', 'en')
* @returns A complete HTML5 document string
*/
export const generateHtmlDocument = (
documentTitle: string,
editorHtmlWithLocalMedia: string,
lang: string,
): string => {
return `<!DOCTYPE html>
<html lang="${lang}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(documentTitle)}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main role="main">
${editorHtmlWithLocalMedia}
</main>
</body>
</html>`;
};
export const addMediaFilesToZip = async (
parsedDocument: Document,
zip: JSZip,
mediaUrl: string,
) => {
const mediaFiles: { filename: string; blob: Blob }[] = [];
const mediaElements = Array.from(
parsedDocument.querySelectorAll<
HTMLImageElement | HTMLVideoElement | HTMLAudioElement | HTMLSourceElement
>('img, video, audio, source'),
);
await Promise.all(
mediaElements.map(async (element, index) => {
const src = element.getAttribute('src');
if (!src) {
return;
}
// data: URLs are already embedded and work offline; no need to create separate files.
if (src.startsWith('data:')) {
return;
}
// Only download same-origin resources (internal media like /media/...).
// External URLs keep their original src and are not included in the ZIP
let url: URL | null = null;
try {
url = new URL(src, mediaUrl);
} catch {
url = null;
}
if (!url || url.origin !== mediaUrl) {
return;
}
const fetched = await exportResolveFileUrl(url.href);
if (!(fetched instanceof Blob)) {
return;
}
const filename = deriveMediaFilename({
src: url.href,
index,
blob: fetched,
});
element.setAttribute('src', filename);
mediaFiles.push({ filename, blob: fetched });
}),
);
mediaFiles.forEach(({ filename, blob }) => {
zip.file(filename, blob);
});
};
@@ -1,86 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { Fragment } from 'react';
import { beforeEach, describe, expect, vi } from 'vitest';
import { AbstractAnalytic, Analytics } from '@/libs';
import { AppWrapper } from '@/tests/utils';
import { DocToolBox } from '../components/DocToolBox';
let flag = true;
class TestAnalytic extends AbstractAnalytic {
public constructor() {
super();
}
public Provider() {
return <Fragment />;
}
public trackEvent() {}
public isFeatureFlagActivated(flagName: string): boolean {
if (flagName === 'CopyAsHTML') {
return flag;
}
return true;
}
}
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({
push: vi.fn(),
}),
}));
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
beforeEach(() => {
Analytics.clearAnalytics();
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
});
describe('DocToolBox "Copy as HTML" option', () => {
test('renders "Copy as HTML" option when feature flag is enabled', async () => {
new TestAnalytic();
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = await screen.findByLabelText(
'Open the document options',
);
await userEvent.click(optionsButton);
expect(await screen.findByText('Copy as HTML')).toBeInTheDocument();
});
test('does not render "Copy as HTML" option when feature flag is disabled', async () => {
flag = false;
new TestAnalytic();
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = screen.getByLabelText('Open the document options');
await userEvent.click(optionsButton);
expect(screen.queryByText('Copy as HTML')).not.toBeInTheDocument();
});
test('render "Copy as HTML" option when we did not add analytics', async () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = screen.getByLabelText('Open the document options');
await userEvent.click(optionsButton);
expect(screen.getByText('Copy as HTML')).toBeInTheDocument();
});
});
@@ -42,10 +42,11 @@ describe('DocToolBox - Licence', () => {
});
const optionsButton = await screen.findByLabelText('Export the document');
await userEvent.click(optionsButton);
// Wait for the export modal to be visible, then assert on its content text.
await screen.findByTestId('modal-export-title');
expect(
await screen.findByText(
'Download your document in a .docx, .odt or .pdf format.',
),
screen.getByText(/Download your document in a .docx, .odt.*format\./i),
).toBeInTheDocument();
}, 10000);
@@ -33,7 +33,6 @@ import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
@@ -67,7 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
void router.push(`/docs/${data.id}`);
},
});
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalidQueries: [KEY_LIST_DOC, KEY_DOC],
});
@@ -155,14 +153,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
show: isFeatureFlagActivated('CopyAsHTML'),
showSeparator: true,
},
{
@@ -4,12 +4,13 @@ import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, useDocUtils, useTrans } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import ChildDocument from '../assets/child-document.svg';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
import SimpleFileIcon from '../assets/simple-document.svg';
import { useDocUtils, useTrans } from '../hooks';
import { Doc } from '../types';
const ItemTextCss = css`
overflow: hidden;
@@ -4,10 +4,10 @@ import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { AlertModal, Card, Text } from '@/components';
import { Doc, KEY_LIST_DOC } from '@/docs/doc-management';
import { Doc, KEY_LIST_DOC, useTrans } from '@/docs/doc-management';
import {
getDocAccesses,
getDocInvitations,
@@ -60,6 +60,7 @@ export const DraggableDocGridContentList = ({
const { mutate: handleDeleteInvitation } = useDeleteDocInvitation();
const { mutate: handleDeleteAccess } = useDeleteDocAccess();
const onDragData = useRef<DocDragEndData | null>(null);
const { untitledDocument } = useTrans();
const handleMoveDoc = async () => {
if (!onDragData.current) {
@@ -144,8 +145,8 @@ export const DraggableDocGridContentList = ({
return t('You must be at least the administrator of the target document');
}
return selectedDoc?.title || t('Unnamed document');
}, [canDrag, canDrop, selectedDoc, t]);
return selectedDoc?.title || untitledDocument;
}, [canDrag, canDrop, selectedDoc?.title, t, untitledDocument]);
const cannotMoveDoc =
!canDrag || (canDrop !== undefined && !canDrop) || isError;
@@ -193,17 +194,16 @@ export const DraggableDocGridContentList = ({
{...modalConfirmation}
title={t('Move document')}
description={
<span
dangerouslySetInnerHTML={{
__html: t(
'By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>',
{
targetDocumentTitle:
onDragData.current?.target.title ?? t('Unnamed document'),
},
),
}}
/>
<Text $display="inline">
<Trans
i18nKey="By moving this document to <strong>{{targetDocumentTitle}}</strong>, it will lose its current access rights and inherit the permissions of that document. <strong>This access change cannot be undone.</strong>"
values={{
targetDocumentTitle:
onDragData.current?.target.title ?? untitledDocument,
}}
components={{ strong: <strong /> }}
/>
</Text>
}
confirmLabel={t('Move')}
onConfirm={() => {
@@ -12,12 +12,16 @@ export const ButtonTogglePanel = () => {
<Button
size="medium"
onClick={() => togglePanel()}
aria-label={t('Open the header menu')}
aria-label={t(
isPanelOpen ? 'Close the header menu' : 'Open the header menu',
)}
aria-expanded={isPanelOpen}
variant="tertiary"
icon={
<Icon $withThemeInherited iconName={isPanelOpen ? 'close' : 'menu'} />
}
className="--docs--button-toggle-panel"
data-testid="header-menu-toggle"
/>
);
};
@@ -2,7 +2,7 @@ import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components/';
import { Box, SkipToContent, StyledLink } from '@/components/';
import { useConfig } from '@/core/config';
import { useCunninghamTheme } from '@/cunningham';
import { ButtonLogin } from '@/features/auth';
@@ -25,72 +25,76 @@ export const Header = () => {
config?.theme_customization?.header?.icon || componentTokens.icon;
return (
<Box
as="header"
role="banner"
$css={css`
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: ${HEADER_HEIGHT}px;
padding: 0 ${spacingsTokens['base']};
background-color: var(--c--contextuals--background--surface--primary);
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
`}
className="--docs--header"
>
{!isDesktop && <ButtonTogglePanel />}
<StyledLink
href="/"
data-testid="header-logo-link"
aria-label={t('Back to homepage')}
<>
<SkipToContent />
<Box
as="header"
role="banner"
$css={css`
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
border-radius: var(--c--globals--spacings--st);
}
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: ${HEADER_HEIGHT}px;
padding: 0 ${spacingsTokens['base']};
background-color: var(--c--contextuals--background--surface--primary);
border-bottom: 1px solid
var(--c--contextuals--border--surface--primary);
`}
className="--docs--header"
>
<Box
$align="center"
$gap={spacingsTokens['3xs']}
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
{!isDesktop && <ButtonTogglePanel />}
<StyledLink
href="/"
data-testid="header-logo-link"
aria-label={t('Back to homepage')}
$css={css`
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Image
data-testid="header-icon-docs"
src={icon.src || ''}
alt=""
width={0}
height={0}
style={{
width: icon.width,
height: icon.height,
}}
priority
/>
<Title headingLevel="h1" aria-hidden="true" />
</Box>
</StyledLink>
{!isDesktop ? (
<Box $direction="row" $gap={spacingsTokens['sm']}>
<LaGaufre />
</Box>
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
</Box>
<Box
$align="center"
$gap={spacingsTokens['3xs']}
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image
data-testid="header-icon-docs"
src={icon.src || ''}
alt=""
width={0}
height={0}
style={{
width: icon.width,
height: icon.height,
}}
priority
/>
<Title headingLevel="h1" aria-hidden="true" />
</Box>
</StyledLink>
{!isDesktop ? (
<Box $direction="row" $gap={spacingsTokens['sm']}>
<LaGaufre />
</Box>
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
</Box>
</>
);
};
@@ -5,6 +5,7 @@ import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Footer } from '@/features/footer';
import { LeftPanel } from '@/features/left-panel';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useResponsiveStore } from '@/stores';
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
@@ -34,8 +35,19 @@ export function HomeContent() {
<Box
as="main"
role="main"
id={MAIN_LAYOUT_ID}
tabIndex={-1}
className="--docs--home-content"
aria-label={t('Main content')}
$css={css`
&:focus {
outline: 3px solid var(--c--theme--colors--primary-600);
outline-offset: -3px;
}
&:focus:not(:focus-visible) {
outline: none;
}
`}
>
<HomeHeader />
{isSmallMobile && (
@@ -12,7 +12,7 @@ import { useLeftPanelStore } from '../stores';
export const LeftPanelHeaderButton = () => {
const router = useRouter();
const { t } = useTranslation();
const { togglePanel } = useLeftPanelStore();
const { closePanel } = useLeftPanelStore();
const { setIsSkeletonVisible } = useSkeletonStore();
const [isNavigating, setIsNavigating] = useState(false);
@@ -25,7 +25,7 @@ export const LeftPanelHeaderButton = () => {
.then(() => {
// The skeleton will be disabled by the [id] page once the data is loaded
setIsNavigating(false);
togglePanel();
closePanel();
})
.catch(() => {
// In case of navigation error, disable the skeleton
@@ -6,6 +6,8 @@ import {
PanelResizeHandle,
} from 'react-resizable-panels';
import { useResponsiveStore } from '@/stores';
// Convert a target pixel width to a percentage of the current viewport width.
const pxToPercent = (px: number) => {
return (px / window.innerWidth) * 100;
@@ -24,18 +26,27 @@ export const ResizableLeftPanel = ({
minPanelSizePx = 300,
maxPanelSizePx = 450,
}: ResizableLeftPanelProps) => {
const { isDesktop } = useResponsiveStore();
const ref = useRef<ImperativePanelHandle>(null);
const savedWidthPxRef = useRef<number>(minPanelSizePx);
const [panelSizePercent, setPanelSizePercent] = useState(() =>
pxToPercent(minPanelSizePx),
);
const minPanelSizePercent = pxToPercent(minPanelSizePx);
const maxPanelSizePercent = Math.min(pxToPercent(maxPanelSizePx), 40);
const [panelSizePercent, setPanelSizePercent] = useState(() => {
const initialSize = pxToPercent(minPanelSizePx);
return Math.max(
minPanelSizePercent,
Math.min(initialSize, maxPanelSizePercent),
);
});
// Keep pixel width constant on window resize
useEffect(() => {
if (!isDesktop) {
return;
}
const handleResize = () => {
const newPercent = pxToPercent(savedWidthPxRef.current);
setPanelSizePercent(newPercent);
@@ -48,7 +59,7 @@ export const ResizableLeftPanel = ({
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
}, [isDesktop]);
const handleResize = (sizePercent: number) => {
const widthPx = (sizePercent / 100) * window.innerWidth;
@@ -57,29 +68,29 @@ export const ResizableLeftPanel = ({
};
return (
<>
<PanelGroup direction="horizontal">
<Panel
ref={ref}
order={0}
defaultSize={panelSizePercent}
minSize={minPanelSizePercent}
maxSize={maxPanelSizePercent}
onResize={handleResize}
>
{leftPanel}
</Panel>
<PanelResizeHandle
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
width: '1px',
cursor: 'col-resize',
}}
/>
<Panel order={1}>{children}</Panel>
</PanelGroup>
</>
<PanelGroup direction="horizontal">
<Panel
ref={ref}
order={0}
defaultSize={isDesktop ? panelSizePercent : 0}
minSize={isDesktop ? minPanelSizePercent : 0}
maxSize={isDesktop ? maxPanelSizePercent : 0}
onResize={handleResize}
>
{leftPanel}
</Panel>
<PanelResizeHandle
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: 'var(--c--contextuals--border--surface--primary)',
width: '1px',
cursor: 'col-resize',
}}
disabled={!isDesktop}
/>
<Panel order={1}>{children}</Panel>
</PanelGroup>
);
};
@@ -3,6 +3,7 @@ import { create } from 'zustand';
interface LeftPanelState {
isPanelOpen: boolean;
togglePanel: (value?: boolean) => void;
closePanel: () => void;
}
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
@@ -15,4 +16,7 @@ export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
set({ isPanelOpen: sanitizedValue });
},
closePanel: () => {
set({ isPanelOpen: false });
},
}));
@@ -151,7 +151,6 @@
"Open document: {{title}}": "Digeriñ ar restr: {{title}}",
"Open root document": "Digeriñ ar restr gwrizienn",
"Open the document options": "Digeriñ dibarzhioù ar restr",
"Open the header menu": "Digeriñ lañser an talbennoù",
"Open the menu of actions for the document: {{title}}": "Digeriñ lañser oberezhioù evit ar restr: {{title}}",
"Organize": "Aozañ",
"Others are editing this document. Unfortunately your network blocks WebSockets, the technology enabling real-time co-editing.": "Tud all a zo oc'h aozañ ar restr-mañ. Siwazh e stank ho kenrouedad ar WebSockets, an deknologiezh a ro an tu da skrivañ a-stroll war an dro.",
@@ -215,7 +214,6 @@
"Too many requests. Please wait 60 seconds.": "Re a c'houlennoù. Gortozit 60 eilenn mar plij.",
"Type a name or email": "Skrivit hoc'h anv pe ur postel",
"Type the name of a document": "Skrivit anv ur restr",
"Unnamed document": "Teul hep titl",
"Unpin": "Dispilhennañ",
"Untitled document": "Restr hep titl",
"Updated": "Hizivaet",
@@ -333,7 +331,6 @@
"Docx": "Docx",
"Download": "Herunterladen",
"Download anyway": "Trotzdem herunterladen",
"Download your document in a .docx, .odt or .pdf format.": "Dokument als DOCX-, ODT- oder PDF-Datei exportieren.",
"Duplicate": "Duplizieren",
"Edit document emoji": "Dokumenten-Symbol bearbeiten",
"Editing": "Bearbeiten",
@@ -412,7 +409,6 @@
"Open document: {{title}}": "Öffne Dokument: {{title}}",
"Open root document": "Öffne Wurzel-Dokument",
"Open the document options": "Öffnen Sie die Dokumentoptionen",
"Open the header menu": "Öffne das Kopfzeilen-Menü",
"Open the menu of actions for the document: {{title}}": "Öffne das Aktionsmenü für das Dokument: {{title}}",
"Open the sharing settings for the document": "Öffne die Freigabeeinstellungen für das Dokument",
"Organize": "Organisieren",
@@ -486,7 +482,6 @@
"Trashbin": "Papierkorb",
"Type a name or email": "Geben Sie einen Namen oder eine E-Mail-Adresse ein",
"Type the name of a document": "Geben Sie den Namen eines Dokuments ein",
"Unnamed document": "Unbenanntes Dokument",
"Unpin": "Lösen",
"Untitled document": "Unbenanntes Dokument",
"Updated": "Aktualisiert",
@@ -632,7 +627,6 @@
"Only invited people can access": "Solo las personas invitadas pueden acceder",
"Open Source": "Código abierto",
"Open the document options": "Abrir las opciones del documento",
"Open the header menu": "Abrir el menú de encabezado",
"Organize": "Organiza",
"Owner": "Propietario",
"PDF": "PDF",
@@ -795,7 +789,7 @@
"Docx": "Docx",
"Download": "Télécharger",
"Download anyway": "Télécharger malgré tout",
"Download your document in a .docx, .odt or .pdf format.": "Téléchargez votre document au format .docx, .odt ou .pdf.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Téléchargez votre document au format .docx, .odt, .pdf ou .html(zip).",
"Drag and drop status": "État du glisser-déposer",
"Duplicate": "Dupliquer",
"Edit document emoji": "Modifier l'émoticône du document",
@@ -819,7 +813,9 @@
"Failed to duplicate the document...": "Échec de la duplication du document...",
"Flexible export.": "Un export flexible.",
"Format": "Format",
"Go to content": "Voir le contenu",
"Govs ❤️ Open Source.": "Gouvernements ❤️ Open Source.",
"HTML": "HTML",
"History": "Historique",
"Home": "Accueil",
"I understand": "Jai compris",
@@ -880,7 +876,6 @@
"Open document: {{title}}": "Ouvrir le document : {{title}}",
"Open root document": "Ouvrir le document racine",
"Open the document options": "Ouvrir les options du document",
"Open the header menu": "Ouvrir le menu d'en-tête",
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu des actions du document : {{title}}",
"Open the sharing settings for the document": "Ouvrir les paramètres de partage pour le document",
"Organize": "Organiser",
@@ -943,6 +938,7 @@
"The document has been deleted.": "Le document a bien été supprimé.",
"The document has been restored.": "Le document a été restauré.",
"The document visibility has been updated.": "La visibilité du document a été mise à jour.",
"The document visibility restored.": "La visibilité du document a été restaurée.",
"The export failed": "Lexportation a échoué",
"The link sharing rules differ from the parent document": "Les règles de partage du lien diffèrent du document parent",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Ce document et <strong>tous les sous-documents</strong> seront placés dans la corbeille. Vous pouvez le restaurer dans {{days}} jours.",
@@ -955,7 +951,6 @@
"Trashbin": "Corbeille",
"Type a name or email": "Tapez un nom ou un email",
"Type the name of a document": "Tapez le nom d'un document",
"Unnamed document": "Document sans titre",
"Unpin": "Désépingler",
"Untitled document": "Document sans titre",
"Updated": "Mise à jour",
@@ -1078,7 +1073,6 @@
"Only invited people can access": "Solo le persone invitate possono accedere",
"Open Source": "Open Source",
"Open the document options": "Apri le opzioni del documento",
"Open the header menu": "Apri il menu dell'intestazione",
"Organize": "Organizza",
"Owner": "Proprietario",
"PDF": "PDF",
@@ -1174,6 +1168,8 @@
"Cancel": "Annuleren",
"Cancel the deletion": "Verwijderen annuleren",
"Cancel the download": "Annuleer de download",
"Change role for {{email}}": "Rol wijzigen voor {{email}}",
"Change role for {{name}}": "Rol wijzigen voor {{name}}",
"Close the access request modal": "Sluit het toegangsverzoek venster",
"Close the delete modal": "Sluit het verwijder venster",
"Close the download modal": "Sluit het downloadvenster",
@@ -1222,12 +1218,14 @@
"Document sections": "Document secties",
"Document title": "Documenttitel",
"Document tree": "Boomstructuur document",
"Document viewer": "Document-viewer",
"Document visibility": "Document toegankelijkheid",
"Documents grid": "Documenten overzicht",
"Docx": "Docx",
"Download": "Download",
"Download anyway": "Download alsnog",
"Download your document in a .docx, .odt or .pdf format.": "Download uw document in een .docx, .odt of .pdf formaat.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Download uw document in een .docx, .odt, .pdf of .html(zip) formaat.",
"Drag and drop status": "Drag & drop status",
"Duplicate": "Dupliceer",
"Edit document emoji": "Bewerk document emoji",
"Editing": "Bewerken",
@@ -1250,7 +1248,9 @@
"Failed to duplicate the document...": "Het dupliceren van het document is mislukt...",
"Flexible export.": "Flexibele export.",
"Format": "Formaat",
"Go to content": "Ga naar inhoud",
"Govs ❤️ Open Source.": "Govs ❤️ Open Source.",
"HTML": "HTML",
"History": "Geschiedenis",
"Home": "Startscherm",
"I understand": "Ik begrijp het",
@@ -1261,6 +1261,7 @@
"Image: {{title}}": "Afbeelding: {{title}}",
"Insufficient access rights to view the document.": "Onvoldoende toegangsrechten om het document te bekijken.",
"Invite": "Uitnodigen",
"Invite new members": "Nieuwe leden uitnodigen",
"Invite {{count}} members_many": "Nodig {{count}} leden uit",
"Invite {{count}} members_one": "Nodig {{count}} leden uit",
"Invite {{count}} members_other": "Nodig {{count}} leden uit",
@@ -1310,7 +1311,6 @@
"Open document: {{title}}": "Open document: {{title}}",
"Open root document": "Open hoofddocument",
"Open the document options": "Open document opties",
"Open the header menu": "Open het hoofdmenu",
"Open the menu of actions for the document: {{title}}": "Open het menu van acties voor het document: {{title}}",
"Open the sharing settings for the document": "Open de instellingen voor delen van het document",
"Organize": "Organiseer",
@@ -1373,6 +1373,7 @@
"The document has been deleted.": "Het document is verwijderd",
"The document has been restored.": "Het document is hersteld.",
"The document visibility has been updated.": "De toegang van het document is bijgewerkt.",
"The document visibility restored.": "De zichtbaarheid van het document is hersteld.",
"The export failed": "Het exporteren is mislukt",
"The link sharing rules differ from the parent document": "De regels voor het delen via een link verschillen van het bovenliggende document",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Dit document en <strong>alle sub-documenten</strong> zullen in de prullenbak worden geplaatst. Je kunt het binnen {{days}} dagen herstellen.",
@@ -1385,7 +1386,6 @@
"Trashbin": "Prullenbak",
"Type a name or email": "Type een naam of email",
"Type the name of a document": "Vul de naam van een document in",
"Unnamed document": "Naamloos document",
"Unpin": "Losmaken",
"Untitled document": "Naamloos document",
"Updated": "Bijgewerkt",
@@ -1415,7 +1415,10 @@
"home-content-open-source-part1": "Docs is gebouwd op <2>Django Rest Framework</2> en <6>Next.js</6>. We gebruiken ook <9>Yjs</9> en <13>BlockNote.js</13>, twee projecten die we met trots sponsoren.",
"home-content-open-source-part2": "U kunt Docs eenvoudig zelf hosten (zie onze <2>installatiedocumentatie</2>).<br/>Docs gebruikt een <7>licentie</7> (MIT) die is afgestemd op innovatie en ondernemingen.<br/>Bijdragen zijn welkom (zie onze routekaart <13>hier</13>).",
"home-content-open-source-part3": "Docs is het resultaat van een gezamenlijke inspanning geleid door de Franse 🇫🇷🥖 <1>(DINUM)</1> en Duitse 🇩🇪🥨 <5>(ZenDiS)</5> overheden.",
"pdf": "pdf"
"pdf": "pdf",
"{{action}}, current role: {{role}}": "{{action}}, huidige rol: {{role}}",
"{{name}} added to invite list. Add more members or press Tab to select role and invite.": "{{name}} toegevoegd aan de uitnodigingslijst. Voeg meer deelnemers toe of druk op Tab om de rol en uitnodiging te selecteren.",
"{{name}} removed from invite list": "{{name}} verwijderd uit de uitnodigingslijst"
}
},
"pt": { "translation": {} },
@@ -1517,7 +1520,7 @@
"Docx": "Docx",
"Download": "Загрузить",
"Download anyway": "Всё равно загрузить",
"Download your document in a .docx, .odt or .pdf format.": "Загрузить документ в формате .docx, .odt или .pdf.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Загрузить документ в формате .docx, .odt, .pdf или .html(zip).",
"Drag and drop status": "Состояние перетаскивания",
"Duplicate": "Дублировать",
"Edit document emoji": "Изменить документ emoji",
@@ -1541,7 +1544,9 @@
"Failed to duplicate the document...": "Не удалось дублировать документ...",
"Flexible export.": "Полезные форматы экспорта.",
"Format": "Формат",
"Go to content": "Перейти к содержимому",
"Govs ❤️ Open Source.": "Govs ❤️ Open Source.",
"HTML": "HTML",
"History": "История изменений",
"Home": "Главная",
"I understand": "Я понимаю",
@@ -1602,7 +1607,6 @@
"Open document: {{title}}": "Открыть документ: {{title}}",
"Open root document": "Открыть корневой документ",
"Open the document options": "Открыть параметры документа",
"Open the header menu": "Открыть меню заголовка",
"Open the menu of actions for the document: {{title}}": "Открыть меню действий для документа: {{title}}",
"Open the sharing settings for the document": "Открыть настройки общего доступа к документу",
"Organize": "Организация данных",
@@ -1665,6 +1669,7 @@
"The document has been deleted.": "Документ удалён.",
"The document has been restored.": "Документ восстановлен.",
"The document visibility has been updated.": "Видимость документа обновлена.",
"The document visibility restored.": "Видимость документа восстановлена.",
"The export failed": "Ошибка при экспорте",
"The link sharing rules differ from the parent document": "Правила обмена ссылками отличаются от правил родительского документа",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Этот документ и <strong>все вложенные в него документы</strong> будут помещены в корзину. Вы можете восстановить их в течение {{days}} дн.",
@@ -1677,7 +1682,6 @@
"Trashbin": "Корзина",
"Type a name or email": "Введите имя или e-mail",
"Type the name of a document": "Введите название документа",
"Unnamed document": "Безымянный документ",
"Unpin": "Открепить",
"Untitled document": "Безымянный документ",
"Updated": "Обновлено",
@@ -1915,7 +1919,7 @@
"Docx": "Docx",
"Download": "Завантажити",
"Download anyway": "Все одно завантажити",
"Download your document in a .docx, .odt or .pdf format.": "Завантажити документ у форматі .docx, .odt або .pdf.",
"Download your document in a .docx, .odt, .pdf or .html(zip) format.": "Завантажити документ у форматі .docx, .odt, .pdf або .html(zip).",
"Drag and drop status": "Стан перетягування",
"Duplicate": "Дублювати",
"Edit document emoji": "Редагувати документ емодзі",
@@ -1939,7 +1943,9 @@
"Failed to duplicate the document...": "Не вдалося дублювати документ...",
"Flexible export.": "Гнучкий експорт.",
"Format": "Формат",
"Go to content": "Перейти до вмісту",
"Govs ❤️ Open Source.": "Govs ❤️ Open Source.",
"HTML": "HTML",
"History": "Історія",
"Home": "Головна",
"I understand": "Я розумію",
@@ -2000,7 +2006,6 @@
"Open document: {{title}}": "Відкрити документ: {{title}}",
"Open root document": "Відкрити кореневий документ",
"Open the document options": "Відкрити параметри документа",
"Open the header menu": "Відкрити меню заголовка",
"Open the menu of actions for the document: {{title}}": "Відкрити меню дій для документа: {{title}}",
"Open the sharing settings for the document": "Відкрити параметри спільного доступу для документа",
"Organize": "Організуйте",
@@ -2063,6 +2068,7 @@
"The document has been deleted.": "Документ був видалений.",
"The document has been restored.": "Документ був відновлений.",
"The document visibility has been updated.": "Видимість документа оновлено.",
"The document visibility restored.": "Видимість документа відновлено.",
"The export failed": "Не вдалося виконати експорт",
"The link sharing rules differ from the parent document": "Правила обміну посиланнями відрізняються від батьківського документа",
"This document and <strong>any sub-documents</strong> will be placed in the trashbin. You can restore it within {{days}} days.": "Цей документ та <strong>всі вкладені документи</strong> будуть переміщені до кошика. Ви можете відновити їх протягом {{days}} дн.",
@@ -2075,7 +2081,6 @@
"Trashbin": "Кошик",
"Type a name or email": "Введіть ім'я або електронну адресу",
"Type the name of a document": "Введіть назву документа",
"Unnamed document": "Документ без назви",
"Unpin": "Відкріпити",
"Untitled document": "Документ без назви",
"Updated": "Оновлено",
@@ -2223,7 +2228,6 @@
"Open Source": "开放源代码",
"Open document {{title}}": "打开文档{{title}}",
"Open the document options": "打开文档选项",
"Open the header menu": "打开标题菜单",
"Organize": "组织",
"Owner": "所有者",
"PDF": "PDF",
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Header } from '@/features/header';
import { HEADER_HEIGHT } from '@/features/header/conf';
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
@@ -52,15 +53,57 @@ export function MainLayoutContent({
enableResizablePanel = false,
}: PropsWithChildren<MainLayoutContentProps>) {
const { isDesktop } = useResponsiveStore();
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</ResizableLeftPanel>
);
}
if (!isDesktop) {
return (
<>
<LeftPanel />
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</>
);
}
return (
<>
<Box
$css={css`
width: 300px;
border-right: 1px solid
var(--c--contextuals--border--surface--primary);
`}
>
<LeftPanel />
</Box>
<MainContent backgroundColor={backgroundColor}>{children}</MainContent>
</>
);
}
const MainContent = ({
children,
backgroundColor,
}: PropsWithChildren<MainLayoutContentProps>) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
const mainContent = (
return (
<Box
as="main"
role="main"
aria-label={t('Main content')}
id={MAIN_LAYOUT_ID}
tabIndex={-1}
$align="center"
$flex={1}
$width="100%"
@@ -77,6 +120,10 @@ export function MainLayoutContent({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus {
outline: 3px solid ${colorsTokens['brand-400']};
outline-offset: -3px;
}
`}
>
<Skeleton>
@@ -85,36 +132,4 @@ export function MainLayoutContent({
{children}
</Box>
);
if (!isDesktop) {
return (
<>
<LeftPanel />
{mainContent}
</>
);
}
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
{mainContent}
</ResizableLeftPanel>
);
}
return (
<>
<Box
$css={css`
width: 300px;
border-right: 1px solid
var(--c--contextuals--border--surface--primary);
`}
>
<LeftPanel />
</Box>
{mainContent}
</>
);
}
};
@@ -1,5 +1,6 @@
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { Footer } from '@/features/footer';
@@ -7,6 +8,8 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { MAIN_LAYOUT_ID } from './conf';
interface PageLayoutProps {
withFooter?: boolean;
}
@@ -27,8 +30,19 @@ export function PageLayout({
<Box
as="main"
role="main"
id={MAIN_LAYOUT_ID}
tabIndex={-1}
$width="100%"
$css="flex-grow:1;"
$css={css`
flex-grow: 1;
&:focus {
outline: 3px solid var(--c--theme--colors--primary-600);
outline-offset: -3px;
}
&:focus:not(:focus-visible) {
outline: none;
}
`}
aria-label={t('Main content')}
>
{!isDesktop && <LeftPanel />}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "4.0.0",
"version": "4.1.0",
"private": true,
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-docs",
"version": "4.0.0",
"version": "4.1.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "4.0.0",
"version": "4.1.0",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "4.0.0",
"version": "4.1.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/suitenumerique/docs",
"license": "MIT",
+49 -49
View File
@@ -2530,10 +2530,10 @@
"@emnapi/runtime" "^1.4.3"
"@tybys/wasm-util" "^0.10.0"
"@next/env@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.4.tgz#1d4aa6b238662d9cd95aea356b149b6f73061f95"
integrity sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==
"@next/env@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.5.7.tgz#4168db34ae3bc9fd9ad3b951d327f4cfc38d4362"
integrity sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==
"@next/eslint-plugin-next@16.0.3":
version "16.0.3"
@@ -2542,45 +2542,45 @@
dependencies:
fast-glob "3.3.1"
"@next/swc-darwin-arm64@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz#80cba1bec831d4b01fd03cbc48dfb7050775e5ee"
integrity sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==
"@next/swc-darwin-arm64@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz#f0c9ccfec2cd87cbd4b241ce4c779a7017aed958"
integrity sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==
"@next/swc-darwin-x64@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz#d5408b19298f40da2b3dc9c2f9d1063ad98bd626"
integrity sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==
"@next/swc-darwin-x64@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz#18009e9fcffc5c0687cc9db24182ddeac56280d9"
integrity sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==
"@next/swc-linux-arm64-gnu@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz#3b6b389bb4a1c9728a14afbbd59d2366ccd80b55"
integrity sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==
"@next/swc-linux-arm64-gnu@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz#fe7c7e08264cf522d4e524299f6d3e63d68d579a"
integrity sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==
"@next/swc-linux-arm64-musl@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz#956127ecdfd56cda535af4651eed72a3b7270971"
integrity sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==
"@next/swc-linux-arm64-musl@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz#94228fe293475ec34a5a54284e1056876f43a3cf"
integrity sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==
"@next/swc-linux-x64-gnu@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz#9386de65e86c0b34ef19e14f0ffbd4328a08d5e6"
integrity sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==
"@next/swc-linux-x64-gnu@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz#078c71201dfe7fcfb8fa6dc92aae6c94bc011cdc"
integrity sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==
"@next/swc-linux-x64-musl@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz#c9094e5479b58c89d35b465f165b69be68de5a75"
integrity sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==
"@next/swc-linux-x64-musl@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz#72947f5357f9226292353e0bb775643da3c7a182"
integrity sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==
"@next/swc-win32-arm64-msvc@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz#e83ca6b5ce9499bde5a4f3351cf74dc9e92cc83e"
integrity sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==
"@next/swc-win32-arm64-msvc@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz#397b912cd51c6a80e32b9c0507ecd82514353941"
integrity sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==
"@next/swc-win32-x64-msvc@15.5.4":
version "15.5.4"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz#5b5baf1bcb0ecba70d1768a0c8be59dfdcb2f111"
integrity sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==
"@next/swc-win32-x64-msvc@15.5.7":
version "15.5.7"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz#e02b543d9dc6c1631d4ac239cb1177245dfedfe4"
integrity sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==
"@noble/hashes@^2.0.1":
version "2.0.1"
@@ -12080,25 +12080,25 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
next@15.5.4:
version "15.5.4"
resolved "https://registry.yarnpkg.com/next/-/next-15.5.4.tgz#e7412c805c0b686ceaf294de703b7c9be59a4081"
integrity sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==
next@15.5.7:
version "15.5.7"
resolved "https://registry.yarnpkg.com/next/-/next-15.5.7.tgz#4507700b2bbcaf2c9fb7a9ad25c0dac2ba4a9a75"
integrity sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==
dependencies:
"@next/env" "15.5.4"
"@next/env" "15.5.7"
"@swc/helpers" "0.5.15"
caniuse-lite "^1.0.30001579"
postcss "8.4.31"
styled-jsx "5.1.6"
optionalDependencies:
"@next/swc-darwin-arm64" "15.5.4"
"@next/swc-darwin-x64" "15.5.4"
"@next/swc-linux-arm64-gnu" "15.5.4"
"@next/swc-linux-arm64-musl" "15.5.4"
"@next/swc-linux-x64-gnu" "15.5.4"
"@next/swc-linux-x64-musl" "15.5.4"
"@next/swc-win32-arm64-msvc" "15.5.4"
"@next/swc-win32-x64-msvc" "15.5.4"
"@next/swc-darwin-arm64" "15.5.7"
"@next/swc-darwin-x64" "15.5.7"
"@next/swc-linux-arm64-gnu" "15.5.7"
"@next/swc-linux-arm64-musl" "15.5.7"
"@next/swc-linux-x64-gnu" "15.5.7"
"@next/swc-linux-x64-musl" "15.5.7"
"@next/swc-win32-arm64-msvc" "15.5.7"
"@next/swc-win32-x64-msvc" "15.5.7"
sharp "^0.34.3"
no-case@^3.0.4:
+2 -2
View File
@@ -1,10 +1,10 @@
environments:
dev:
values:
- version: 4.0.0
- version: 4.1.0
feature:
values:
- version: 4.0.0
- version: 4.1.0
feature: ci
domain: example.com
imageTag: demo
+1 -1
View File
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 4.0.0
version: 4.1.0
appVersion: latest
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "4.0.0",
"version": "4.1.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {