Compare commits

..

1 Commits

Author SHA1 Message Date
Manuel Raynaud
7024a23196 🔧(compose) replace minio by rustfs
Minio is no longer maintained, its git repository is archived. We need
to replace it. We decided to choose rustfs
7aac2a2c5b
https://github.com/rustfs/rustfs
2026-02-18 09:52:15 +01:00
29 changed files with 173 additions and 475 deletions

View File

@@ -8,8 +8,6 @@ and this project adheres to
### Added
- ✨(tracking) add UTM parameters to shared document links
- ✨(frontend) add floating bar with leftpanel collapse button #1876
- ✨(frontend) Can print a doc #1832
- ✨(backend) manage reconciliation requests for user accounts #1878
- 👷(CI) add GHCR workflow for forked repo testing #1851
@@ -28,10 +26,6 @@ and this project adheres to
- 🐛(helm) use celery resources instead of backend resources
- 🐛(helm) reverse liveness and readiness for backend deployment
### Security
- 🔒️(secu) fix CVE-2026-26996 with minimatch #1900
## [v4.5.0] - 2026-01-28
### Added

View File

@@ -22,34 +22,44 @@ services:
ports:
- "1081:1080"
minio:
rustfs:
user: ${DOCKER_USER:-1000}
image: minio/minio
image: rustfs/rustfs:latest
environment:
- MINIO_ROOT_USER=impress
- MINIO_ROOT_PASSWORD=password
- RUSTFS_ACCESS_KEY=rustfsadmin
- RUSTFS_SECRET_KEY=rustfsadmin
- RUSTFS_CONSOLE_ENABLE=true
- RUSTFS_ADDRESS=0.0.0.0:9000
- RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
- RUSTFS_CORS_ALLOWED_ORIGINS=*
- RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
ports:
- '9000:9000'
- '9001:9001'
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
test:
[
"CMD",
"sh",
"-c",
"curl -f http://127.0.0.1:9000/health && curl -f http://127.0.0.1:9001/rustfs/console/health",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
minio:
rustfs:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
/usr/bin/mc alias set impress http://rustfs:9000 rustfsadmin rustfsadmin && \
/usr/bin/mc mb impress/impress-media-storage && \
/usr/bin/mc version enable impress/impress-media-storage && \
exit 0;"
@@ -81,16 +91,16 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
celery-dev:
user: ${DOCKER_USER:-1000}
image: impress:backend-development
@@ -131,7 +141,7 @@ services:
frontend-development:
user: "${DOCKER_USER:-1000}"
build:
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: impress-dev
@@ -161,13 +171,13 @@ services:
image: node:22
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
y-provider-development:
user: ${DOCKER_USER:-1000}
build:
build:
context: .
dockerfile: ./src/frontend/servers/y-provider/Dockerfile
target: y-provider-development
@@ -209,7 +219,11 @@ services:
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
test:
[
"CMD-SHELL",
'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3',
]
start_period: 5s
interval: 1s
timeout: 2s
@@ -223,7 +237,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:

View File

@@ -17,9 +17,9 @@ server {
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
# Get resource from rustfs
proxy_pass http://rustfs:9000/impress-media-storage/;
proxy_set_header Host rustfs:9000;
add_header Content-Security-Policy "default-src 'none'" always;
}
@@ -30,7 +30,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";

View File

@@ -27,9 +27,9 @@ IMPRESS_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=impress
AWS_S3_SECRET_ACCESS_KEY=password
AWS_S3_ENDPOINT_URL=http://rustfs:9000
AWS_S3_ACCESS_KEY_ID=rustfsadmin
AWS_S3_SECRET_ACCESS_KEY=rustfsadmin
MEDIA_BASE_URL=http://localhost:8083
# OIDC

View File

@@ -1260,7 +1260,7 @@ class Document(MP_Node, BaseModel):
"brandname": settings.EMAIL_BRAND_NAME,
"document": self,
"domain": domain,
"link": f"{domain}/docs/{self.id}/?utm_source=docssharelink&utm_campaign={self.id}",
"link": f"{domain}/docs/{self.id}/",
"link_label": self.title or str(_("Untitled Document")),
"button_label": _("Open"),
"logo_img": settings.EMAIL_LOGO_IMG,

View File

@@ -1021,10 +1021,7 @@ def test_models_documents__email_invitation__success():
f"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
f"on the following document: {document.title}" in email_content
)
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
assert f"docs/{document.id}/" in email_content
@pytest.mark.parametrize(
@@ -1054,18 +1051,10 @@ def test_models_documents__email_invitation__url_app_param(email_url_app):
# Determine expected domain
if email_url_app:
expected_url = (
f"https://test-example.com/docs/{document.id}/"
f"?utm_source=docssharelink&amp;utm_campaign={document.id}"
)
assert expected_url in email_content
assert f"https://test-example.com/docs/{document.id}/" in email_content
else:
# Default Site domain is example.com
expected_url = (
f"example.com/docs/{document.id}/"
f"?utm_source=docssharelink&amp;utm_campaign={document.id}"
)
assert expected_url in email_content
assert f"example.com/docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_empty_title():
@@ -1096,10 +1085,7 @@ def test_models_documents__email_invitation__success_empty_title():
"Test Sender (sender@example.com) invited you with the role &quot;editor&quot; "
"on the following document: Untitled Document" in email_content
)
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
assert f"docs/{document.id}/" in email_content
def test_models_documents__email_invitation__success_fr():
@@ -1134,10 +1120,7 @@ def test_models_documents__email_invitation__success_fr():
f"Test Sender2 (sender2@example.com) vous a invité avec le rôle &quot;propriétaire&quot; "
f"sur le document suivant : {document.title}" in email_content
)
assert (
f"docs/{document.id}/?utm_source=docssharelink&amp;utm_campaign={document.id}"
in email_content
)
assert f"docs/{document.id}/" in email_content
@mock.patch(

View File

@@ -41,7 +41,7 @@ test.describe('Doc Comments', () => {
// We add a comment with the first user
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -124,7 +124,7 @@ test.describe('Doc Comments', () => {
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
@@ -191,7 +191,7 @@ test.describe('Doc Comments', () => {
/* Delete the last comment remove the thread */
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
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();
@@ -249,9 +249,7 @@ test.describe('Doc Comments', () => {
editor.getByText('Hello, I can edit the document'),
).toBeVisible();
await otherEditor.getByText('Hello').selectText();
await otherPage
.getByRole('button', { name: 'Comment', exact: true })
.click();
await otherPage.getByRole('button', { name: 'Comment' }).click();
const otherThread = otherPage.locator('.bn-thread');
await otherThread
.getByRole('paragraph')
@@ -282,7 +280,7 @@ test.describe('Doc Comments', () => {
await expect(otherThread).toBeHidden();
await otherEditor.getByText('Hello').selectText();
await expect(
otherPage.getByRole('button', { name: 'Comment', exact: true }),
otherPage.getByRole('button', { name: 'Comment' }),
).toBeHidden();
await otherPage.reload();
@@ -336,7 +334,7 @@ test.describe('Doc Comments', () => {
// We add a comment in the first document
const editor1 = await writeInEditor({ page, text: 'Document One' });
await editor1.getByText('Document One').selectText();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
await page.getByRole('button', { name: 'Comment' }).click();
const thread1 = page.locator('.bn-thread');
await thread1.getByRole('paragraph').first().fill('Comment in Doc One');
@@ -390,7 +388,7 @@ test.describe('Doc Comments mobile', () => {
// Checks add react reaction
const editor = await writeInEditor({ page, text: 'Hello' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Comment', exact: true }).click();
await page.getByRole('button', { name: 'Comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');

View File

@@ -410,7 +410,7 @@ test.describe('Doc Editor', () => {
const editor = page.locator('.ProseMirror');
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'AI', exact: true }).click();
await page.getByRole('button', { name: 'AI' }).click();
await expect(
page.getByRole('menuitem', { name: 'Use as prompt' }),
@@ -494,13 +494,11 @@ test.describe('Doc Editor', () => {
await editor.getByText('Hello').selectText();
if (!ai_transform && !ai_translate) {
await expect(
page.getByRole('button', { name: 'AI', exact: true }),
).toBeHidden();
await expect(page.getByRole('button', { name: 'AI' })).toBeHidden();
return;
}
await page.getByRole('button', { name: 'AI', exact: true }).click();
await page.getByRole('button', { name: 'AI' }).click();
if (ai_transform) {
await expect(

View File

@@ -7,7 +7,6 @@ import {
mockedDocument,
verifyDocName,
} from './utils-common';
import { writeInEditor } from './utils-editor';
import {
connectOtherUserToDoc,
mockedAccesses,
@@ -21,43 +20,6 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Header', () => {
test('toggles panel collapse from floating bar button', async ({
page,
browserName,
}) => {
const [docTitle] = await createDoc(
page,
'doc-floating-bar',
browserName,
1,
);
const collapseButton = page.getByTestId('floating-bar-toggle-left-panel');
await expect(collapseButton).toBeVisible();
// Panel open
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
// Collapse panel
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'false');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
// When the title is not visible in the viewport, the button should show the title
const editor = await writeInEditor({ page, text: 'Lorem ipsum' });
for (let i = 0; i < 25; i++) {
await editor.press('Enter');
}
await writeInEditor({ page, text: 'Lorem ipsum 2' });
await expect(collapseButton.getByText(docTitle)).toBeVisible();
// Expand panel and check the title is hidden again
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
});
test('it checks the element are correctly displayed', async ({
page,
browserName,

View File

@@ -2,7 +2,7 @@ import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Box, Loading } from '@/components';
import { DocHeader, FloatingBar } from '@/docs/doc-header/';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
LinkReach,
@@ -35,7 +35,6 @@ export const DocEditorContainer = ({
return (
<>
{isDesktop && <FloatingBar />}
<Box
$maxWidth="868px"
$width="100%"

View File

@@ -35,7 +35,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<>
<Box
$width="100%"
$padding={{ top: isDesktop ? '0' : 'md' }}
$padding={{ top: isDesktop ? '50px' : 'md' }}
$gap={spacingsTokens['base']}
aria-label={t('It is the card information about the document.')}
className="--docs--doc-header"

View File

@@ -18,8 +18,6 @@ import {
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
import { useResponsiveStore } from '@/stores';
export const CLASS_DOC_TITLE = '--docs--doc-title';
interface DocTitleProps {
doc: Doc;
}
@@ -41,15 +39,13 @@ export const DocTitleText = () => {
const { untitledDocument } = useTrans();
return (
<Box className={CLASS_DOC_TITLE} $direction="row" $align="center">
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
{currentDoc?.title || untitledDocument}
</Text>
</Box>
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
>
{currentDoc?.title || untitledDocument}
</Text>
);
};
@@ -69,7 +65,6 @@ const DocTitleEmojiPicker = ({ doc }: DocTitleProps) => {
placement="top"
>
<Box
className={CLASS_DOC_TITLE}
$css={css`
padding: 4px;
padding-top: 3px;

View File

@@ -1,80 +0,0 @@
import { useMemo } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham/useCunninghamTheme';
import { LeftPanelCollapseButton } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
/**
* Sticky bar trick (desktop):
* - MainContent has padding `base`; we extend the bar width and apply
* matching negative margins so it aligns with the scroll area edges.
*
* Mobile: returns null to avoid header overlap.
*/
export const FloatingBar = () => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const FLOATING_STYLES = useMemo(() => {
const base = spacingsTokens['base'];
const sm = spacingsTokens['sm'];
return css`
position: sticky;
top: calc(-${base});
left: 0;
right: 0;
width: calc(100% + ${base} + ${base});
min-height: 64px;
padding: ${sm};
margin-left: calc(-${base});
margin-right: calc(-${base});
margin-top: calc(-${base});
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: flex-start;
isolation: isolate;
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(
180deg,
#fff 0%,
rgba(255, 255, 255, 0) 100%
);
backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(1px);
mask-image: linear-gradient(180deg, black 50%, transparent 100%);
-webkit-mask-image: linear-gradient(
180deg,
black 50%,
transparent 100%
);
}
> * {
position: relative;
z-index: 1;
}
`;
}, [spacingsTokens]);
if (!isDesktop) {
return null;
}
return (
<Box
className="--docs--floating-bar"
data-testid="floating-bar"
$css={FLOATING_STYLES}
>
<LeftPanelCollapseButton />
</Box>
);
};

View File

@@ -1,3 +1,2 @@
export * from './DocHeader';
export * from './DocTitle';
export * from './FloatingBar';

View File

@@ -11,7 +11,7 @@ export const useCopyDocLink = (docId: Doc['id']) => {
return useCallback(() => {
copyToClipboard(
`${window.location.origin}/docs/${docId}/?utm_source=docssharelink&utm_campaign=${docId}`,
`${window.location.origin}/docs/${docId}/`,
t('Link Copied !'),
t('Failed to copy link'),
);

View File

@@ -61,7 +61,7 @@ export const TableContent = () => {
$width={!isOpen ? '40px' : '200px'}
$height={!isOpen ? '40px' : 'auto'}
$maxHeight="calc(50vh - 60px)"
$zIndex={2000}
$zIndex={1000}
$align="center"
$padding={isOpen ? 'xs' : '0'}
$justify="center"

View File

@@ -6,22 +6,19 @@ import { useLeftPanelStore } from '@/features/left-panel';
export const ButtonTogglePanel = () => {
const { t } = useTranslation();
const { isPanelOpenMobile, togglePanel } = useLeftPanelStore();
const { isPanelOpen, togglePanel } = useLeftPanelStore();
return (
<Button
size="medium"
onClick={() => togglePanel()}
aria-label={t(
isPanelOpenMobile ? 'Close the header menu' : 'Open the header menu',
isPanelOpen ? 'Close the header menu' : 'Open the header menu',
)}
aria-expanded={isPanelOpenMobile}
aria-expanded={isPanelOpen}
variant="tertiary"
icon={
<Icon
$withThemeInherited
iconName={isPanelOpenMobile ? 'close' : 'menu'}
/>
<Icon $withThemeInherited iconName={isPanelOpen ? 'close' : 'menu'} />
}
className="--docs--button-toggle-panel"
data-testid="header-menu-toggle"

View File

@@ -1,3 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.04601 20.3381C3.04306 20.3381 2.28465 20.0812 1.77079 19.5673C1.25693 19.0597 1 18.3105 1 17.32V6.01815C1 5.02139 1.25693 4.26917 1.77079 3.7615C2.28465 3.25383 3.04306 3 4.04601 3H19.9447C20.9538 3 21.7154 3.25383 22.2292 3.7615C22.7431 4.26917 23 5.02139 23 6.01815V17.32C23 18.3105 22.7431 19.0597 22.2292 19.5673C21.7154 20.0812 20.9538 20.3381 19.9447 20.3381H4.04601ZM4.15745 18.5087H19.8425C20.2697 18.5087 20.5979 18.3972 20.8269 18.1743C21.056 17.9515 21.1705 17.614 21.1705 17.1621V6.16674C21.1705 5.72098 21.056 5.38666 20.8269 5.16378C20.5979 4.9409 20.2697 4.82946 19.8425 4.82946H4.15745C3.72407 4.82946 3.39285 4.9409 3.16378 5.16378C2.9409 5.38666 2.82946 5.72098 2.82946 6.16674V17.1621C2.82946 17.614 2.9409 17.9515 3.16378 18.1743C3.39285 18.3972 3.72407 18.5087 4.15745 18.5087ZM8.38286 18.8058V4.51372H10.1195V18.8058H8.38286ZM6.56268 8.22837H4.65893C4.49796 8.22837 4.35556 8.16646 4.23174 8.04263C4.11411 7.91881 4.0553 7.78261 4.0553 7.63402C4.0553 7.47305 4.11411 7.33376 4.23174 7.21613C4.35556 7.09849 4.49796 7.03968 4.65893 7.03968H6.56268C6.72984 7.03968 6.87224 7.09849 6.98987 7.21613C7.11369 7.33376 7.1756 7.47305 7.1756 7.63402C7.1756 7.78261 7.11369 7.91881 6.98987 8.04263C6.87224 8.16646 6.72984 8.22837 6.56268 8.22837ZM6.56268 10.7172H4.65893C4.49796 10.7172 4.35556 10.6584 4.23174 10.5407C4.11411 10.4169 4.0553 10.2745 4.0553 10.1136C4.0553 9.95877 4.11411 9.82257 4.23174 9.70494C4.35556 9.58731 4.49796 9.52849 4.65893 9.52849H6.56268C6.72984 9.52849 6.87224 9.58731 6.98987 9.70494C7.11369 9.82257 7.1756 9.95877 7.1756 10.1136C7.1756 10.2745 7.11369 10.4169 6.98987 10.5407C6.87224 10.6584 6.72984 10.7172 6.56268 10.7172ZM6.56268 13.1967H4.65893C4.49796 13.1967 4.35556 13.1379 4.23174 13.0203C4.11411 12.9026 4.0553 12.7664 4.0553 12.6117C4.0553 12.4507 4.11411 12.3114 4.23174 12.1938C4.35556 12.0761 4.49796 12.0173 4.65893 12.0173H6.56268C6.72984 12.0173 6.87224 12.0761 6.98987 12.1938C7.11369 12.3114 7.1756 12.4507 7.1756 12.6117C7.1756 12.7664 7.11369 12.9026 6.98987 13.0203C6.87224 13.1379 6.72984 13.1967 6.56268 13.1967Z" fill="#626A80"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -7,15 +7,13 @@ import { Box, Icon, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocDefaultFilter } from '@/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
export const LeftPanelTargetFilters = () => {
const { t } = useTranslation();
const pathname = usePathname();
const searchParams = useSearchParams();
const { isDesktop } = useResponsiveStore();
const { closePanel } = useLeftPanelStore();
const { togglePanel } = useLeftPanelStore();
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const target =
@@ -51,10 +49,8 @@ export const LeftPanelTargetFilters = () => {
return `${pathname}?${params.toString()}`;
};
const handleFilterClick = () => {
if (!isDesktop) {
closePanel();
}
const handleClick = () => {
togglePanel();
};
return (
@@ -74,7 +70,9 @@ export const LeftPanelTargetFilters = () => {
href={href}
aria-label={query.label}
aria-current={isActive ? 'page' : undefined}
onClick={handleFilterClick}
onClick={() => {
handleClick();
}}
$css={css`
display: flex;
align-items: center;

View File

@@ -26,14 +26,13 @@ export const LeftPanel = () => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { togglePanel, isPanelOpen, isPanelOpenMobile } = useLeftPanelStore();
const isPanelOpenState = isDesktop ? isPanelOpen : isPanelOpenMobile;
const { togglePanel, isPanelOpen } = useLeftPanelStore();
const pathname = usePathname();
useEffect(() => {
togglePanel(isDesktop);
}, [pathname, isDesktop, togglePanel]);
togglePanel(false);
}, [pathname, togglePanel]);
return (
<>
@@ -63,7 +62,7 @@ export const LeftPanel = () => {
{!isDesktop && (
<>
{isPanelOpenState && <MobileLeftPanelStyle />}
{isPanelOpen && <MobileLeftPanelStyle />}
<Box
$hasTransition
$css={css`
@@ -72,7 +71,7 @@ export const LeftPanel = () => {
height: calc(100dvh - 52px);
border-right: 1px solid var(--c--globals--colors--gray-200);
position: fixed;
transform: translateX(${isPanelOpenState ? '0' : '-100dvw'});
transform: translateX(${isPanelOpen ? '0' : '-100dvw'});
background-color: var(--c--globals--colors--gray-000);
overflow-y: auto;
overflow-x: hidden;

View File

@@ -1,91 +0,0 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { CLASS_DOC_TITLE } from '@/docs/doc-header/components/DocTitle';
import { getEmojiAndTitle, useDocStore, useTrans } from '@/docs/doc-management';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import LeftPanelIcon from '../assets/left-panel.svg';
import { useLeftPanelStore } from '../stores';
export const LeftPanelCollapseButton = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isPanelOpen, togglePanel } = useLeftPanelStore();
const { currentDoc } = useDocStore();
const [isDocTitleVisible, setIsDocTitleVisible] = useState(true);
useEffect(() => {
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
const docTitleEl = document.querySelector(`.${CLASS_DOC_TITLE}`);
if (!mainContent || !docTitleEl) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
setIsDocTitleVisible(entry.isIntersecting);
},
{
root: mainContent,
threshold: 0.05,
},
);
observer.observe(docTitleEl);
return () => {
observer.disconnect();
setIsDocTitleVisible(true);
};
}, [currentDoc?.id]);
const { untitledDocument } = useTrans();
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
currentDoc?.title ?? '',
);
const docTitle = titleWithoutEmoji || untitledDocument;
const buttonTitle = emoji ? `${emoji} ${docTitle}` : docTitle;
const shouldShowButtonTitle = !isPanelOpen && !isDocTitleVisible;
const ariaLabel = isPanelOpen
? t('Hide the side panel for {{title}}', { title: docTitle })
: t('Show the side panel for {{title}}', { title: docTitle });
return (
<Box
$css={css`
display: inline-flex;
padding: var(--c--globals--spacings--xxxs);
align-items: center;
gap: var(--c--globals--spacings--xxxs);
border-radius: var(--c--globals--spacings--xs);
border: 1px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`}
>
<Button
size="small"
onClick={() => togglePanel()}
aria-label={ariaLabel}
aria-expanded={isPanelOpen}
color="neutral"
variant="tertiary"
icon={<LeftPanelIcon width={24} height={24} aria-hidden="true" />}
data-testid="floating-bar-toggle-left-panel"
>
{shouldShowButtonTitle ? (
<Text $size="sm" $weight={700} $color={colorsTokens['gray-1000']}>
{buttonTitle}
</Text>
) : undefined}
</Button>
</Box>
);
};

View File

@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { Icon } from '@/components';
import { useCreateDoc } from '@/features/docs/doc-management';
import { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { useLeftPanelStore } from '../stores';
@@ -14,7 +13,6 @@ export const LeftPanelHeaderButton = () => {
const router = useRouter();
const { t } = useTranslation();
const { closePanel } = useLeftPanelStore();
const { isDesktop } = useResponsiveStore();
const { setIsSkeletonVisible } = useSkeletonStore();
const [isNavigating, setIsNavigating] = useState(false);
@@ -27,9 +25,7 @@ export const LeftPanelHeaderButton = () => {
.then(() => {
// The skeleton will be disabled by the [id] page once the data is loaded
setIsNavigating(false);
if (!isDesktop) {
closePanel();
}
closePanel();
})
.catch(() => {
// In case of navigation error, disable the skeleton

View File

@@ -6,7 +6,6 @@ import {
PanelResizeHandle,
} from 'react-resizable-panels';
import { useLeftPanelStore } from '@/features/left-panel/stores';
import { useResponsiveStore } from '@/stores';
// Convert a target pixel width to a percentage of the current viewport width.
@@ -14,9 +13,6 @@ const pxToPercent = (px: number) => {
return (px / window.innerWidth) * 100;
};
const PANEL_TOGGLE_TRANSITION =
'flex-grow 180ms var(--c--globals--transitions--ease-out), flex-basis 180ms var(--c--globals--transitions--ease-out)';
type ResizableLeftPanelProps = {
leftPanel: React.ReactNode;
children: React.ReactNode;
@@ -31,73 +27,44 @@ export const ResizableLeftPanel = ({
maxPanelSizePx = 450,
}: ResizableLeftPanelProps) => {
const { isDesktop } = useResponsiveStore();
const { isPanelOpen } = useLeftPanelStore();
const ref = useRef<ImperativePanelHandle>(null);
const savedWidthPxRef = useRef<number>(minPanelSizePx);
const previousPanelOpenRef = useRef<boolean>(isPanelOpen);
const [isToggleAnimating, setIsToggleAnimating] = useState(false);
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(() => {
const syncPanelState = () => {
if (!ref.current || !isDesktop) {
return;
}
if (!isPanelOpen) {
ref.current.collapse();
return;
}
const restoredSizePercent = Math.max(
minPanelSizePercent,
Math.min(pxToPercent(savedWidthPxRef.current), maxPanelSizePercent),
);
ref.current.expand();
ref.current.resize(restoredSizePercent);
};
const hasPanelToggleChanged = previousPanelOpenRef.current !== isPanelOpen;
previousPanelOpenRef.current = isPanelOpen;
if (hasPanelToggleChanged) {
setIsToggleAnimating(true);
const animationFrameId = requestAnimationFrame(() => {
syncPanelState();
});
const timeoutId = setTimeout(() => {
setIsToggleAnimating(false);
}, 180);
return () => {
window.cancelAnimationFrame(animationFrameId);
window.clearTimeout(timeoutId);
};
}
syncPanelState();
if (!isDesktop || !isPanelOpen) {
if (!isDesktop) {
return;
}
const handleResize = () => {
syncPanelState();
const newPercent = pxToPercent(savedWidthPxRef.current);
setPanelSizePercent(newPercent);
if (ref.current) {
ref.current.resize?.(newPercent - (ref.current.getSize() || 0));
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [isDesktop, isPanelOpen, minPanelSizePercent, maxPanelSizePercent]);
}, [isDesktop]);
const handleResize = (sizePercent: number) => {
if (isDesktop && sizePercent > 0) {
const widthPx = (sizePercent / 100) * window.innerWidth;
savedWidthPxRef.current = widthPx;
}
const widthPx = (sizePercent / 100) * window.innerWidth;
savedWidthPxRef.current = widthPx;
setPanelSizePercent(sizePercent);
};
return (
@@ -106,20 +73,11 @@ export const ResizableLeftPanel = ({
ref={ref}
className="--docs--resizable-left-panel"
order={0}
collapsible
collapsedSize={0}
style={{
overflow: 'hidden',
transition: isToggleAnimating ? PANEL_TOGGLE_TRANSITION : 'none',
}}
defaultSize={
isDesktop
? Math.max(
minPanelSizePercent,
Math.min(
pxToPercent(savedWidthPxRef.current),
maxPanelSizePercent,
),
Math.min(panelSizePercent, maxPanelSizePercent),
)
: 0
}
@@ -137,17 +95,10 @@ export const ResizableLeftPanel = ({
width: '1px',
cursor: 'col-resize',
}}
disabled={!isDesktop || !isPanelOpen}
disabled={!isDesktop}
/>
<Panel
order={1}
style={{
transition: isToggleAnimating ? PANEL_TOGGLE_TRANSITION : 'none',
}}
>
{children}
</Panel>
<Panel order={1}>{children}</Panel>
</PanelGroup>
);
};

View File

@@ -1,3 +1,2 @@
export * from './LeftPanel';
export * from './LeftPanelCollapseButton';
export * from './ResizableLeftPanel';

View File

@@ -2,30 +2,21 @@ import { create } from 'zustand';
interface LeftPanelState {
isPanelOpen: boolean;
isPanelOpenMobile: boolean;
togglePanel: (value?: boolean) => void;
closePanel: () => void;
}
export const useLeftPanelStore = create<LeftPanelState>((set, get) => ({
isPanelOpen: true,
isPanelOpenMobile: false,
isPanelOpen: false,
togglePanel: (value?: boolean) => {
if (value === true) {
set({ isPanelOpen: true });
return;
}
if (value === false) {
set({ isPanelOpen: false, isPanelOpenMobile: false });
return;
}
const { isPanelOpen, isPanelOpenMobile } = get();
set({
isPanelOpen: !isPanelOpen,
isPanelOpenMobile: !isPanelOpenMobile,
});
const sanitizedValue =
value !== undefined && typeof value === 'boolean'
? value
: !get().isPanelOpen;
set({ isPanelOpen: sanitizedValue });
},
closePanel: () => {
set({ isPanelOpen: false, isPanelOpenMobile: false });
set({ isPanelOpen: false });
},
}));

View File

@@ -1,29 +1,13 @@
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
export const useRouteChangeCompleteFocus = () => {
const router = useRouter();
const lastCompletedPathRef = useRef<string | null>(null);
const isKeyboardNavigationRef = useRef(false);
useEffect(() => {
const handleKeyboardNavigation = (event: KeyboardEvent) => {
if (['Tab', 'Enter', ' ', 'Spacebar'].includes(event.key)) {
isKeyboardNavigationRef.current = true;
}
};
window.addEventListener('keydown', handleKeyboardNavigation);
const handleRouteChangeComplete = (url: string) => {
const normalizedUrl = url.split('#')[0];
if (lastCompletedPathRef.current === normalizedUrl) {
return;
}
lastCompletedPathRef.current = normalizedUrl;
const handleRouteChangeComplete = () => {
requestAnimationFrame(() => {
const mainContent =
document.getElementById(MAIN_LAYOUT_ID) ??
@@ -38,13 +22,7 @@ export const useRouteChangeCompleteFocus = () => {
'(prefers-reduced-motion: reduce)',
).matches;
if (isKeyboardNavigationRef.current) {
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
isKeyboardNavigationRef.current = false;
}
if (router.pathname === '/docs/[id]') {
return;
}
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
(firstHeading ?? mainContent)?.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start',
@@ -54,8 +32,7 @@ export const useRouteChangeCompleteFocus = () => {
router.events.on('routeChangeComplete', handleRouteChangeComplete);
return () => {
window.removeEventListener('keydown', handleKeyboardNavigation);
router.events.off('routeChangeComplete', handleRouteChangeComplete);
};
}, [router.events, router.pathname]);
}, [router.events]);
};

View File

@@ -120,13 +120,9 @@ const MainContent = ({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus-visible::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid ${colorsTokens['brand-400']};
pointer-events: none;
z-index: 2001;
&:focus-visible {
outline: 3px solid ${colorsTokens['brand-400']};
outline-offset: -3px;
}
`}
>

View File

@@ -36,7 +36,6 @@
"@types/react": "19.2.8",
"@types/react-dom": "19.2.3",
"eslint": "9.39.2",
"minimatch": "10.2.1",
"prosemirror-view": "1.41.4",
"react": "19.2.3",
"react-dom": "19.2.3",

View File

@@ -8301,16 +8301,16 @@ bail@^2.0.0:
resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d"
integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
balanced-match@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
balanced-match@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.3.tgz#6337a2f23e0604a30481423432f99eac603599f9"
integrity sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==
bare-events@^2.7.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.0.tgz#ec962fa9e2bfafd4edd444942df1ed0c7aba8e4a"
@@ -8382,12 +8382,20 @@ boolbase@^1.0.0:
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
brace-expansion@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f"
integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==
brace-expansion@^1.1.7:
version "1.1.12"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843"
integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==
dependencies:
balanced-match "^4.0.2"
balanced-match "^1.0.0"
concat-map "0.0.1"
brace-expansion@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
dependencies:
balanced-match "^1.0.0"
braces@^3.0.3, braces@~3.0.2:
version "3.0.3"
@@ -8841,6 +8849,11 @@ compute-scroll-into-view@^3.1.0:
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz#02c3386ec531fb6a9881967388e53e8564f3e9aa"
integrity sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
content-disposition@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.0.tgz#844426cb398f934caefcbb172200126bc7ceace2"
@@ -13046,12 +13059,26 @@ minimalistic-assert@^1.0.1:
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimatch@10.2.1, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^5.0.1, minimatch@^9.0.0, minimatch@^9.0.4, minimatch@^9.0.5:
version "10.2.1"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.1.tgz#9d82835834cdc85d5084dd055e9a4685fa56e5f0"
integrity sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==
minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
dependencies:
brace-expansion "^5.0.2"
brace-expansion "^1.1.7"
minimatch@^5.0.1:
version "5.1.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
dependencies:
brace-expansion "^2.0.1"
minimatch@^9.0.0, minimatch@^9.0.4, minimatch@^9.0.5:
version "9.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5"
integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==
dependencies:
brace-expansion "^2.0.1"
minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
version "1.2.8"