Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29ea6b8ef7 | |||
| a692fa6f39 | |||
| 4d541c5d52 | |||
| e5f029ad1d | |||
| bd79f84e07 | |||
| a070f56339 | |||
| 02478acb3f | |||
| 23aa497db0 | |||
| d48436bffb | |||
| 41e4c45934 |
@@ -79,7 +79,9 @@ jobs:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
PUBLISH_AS_MIT=false
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -8,11 +8,23 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨ Add a custom callout block to the editor #892
|
||||
- 🚩(frontend) version MIT only #911
|
||||
|
||||
## Changed
|
||||
|
||||
- 📝(frontend) Update documentation
|
||||
- ✅(frontend) Improve tests coverage
|
||||
|
||||
## [3.2.1] - 2025-05-06
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(frontend) fix list copy paste #943
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
|
||||
|
||||
## [3.2.0] - 2025-05-05
|
||||
|
||||
@@ -20,6 +32,7 @@ and this project adheres to
|
||||
|
||||
- 🚸(backend) make document search on title accent-insensitive #874
|
||||
- 🚩 add homepage feature flag #861
|
||||
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
|
||||
- ✨(settings) Allow configuring PKCE for the SSO #886
|
||||
- 🌐(i18n) activate chinese and spanish languages #884
|
||||
- 🔧(backend) allow overwriting the data directory #893
|
||||
|
||||
+21
-7
@@ -2,16 +2,16 @@
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions on how to run Docs locally.
|
||||
|
||||
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
|
||||
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
## Help us with translations
|
||||
|
||||
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
|
||||
Your language is not there? Request it on our Crowdin page 😊.
|
||||
Your language is not there? Request it on our Crowdin page 😊 or ping us on [Matrix](https://matrix.to/#/#docs-official:matrix.org) and let us know if you can help with translations and/or proofreading.
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
@@ -35,10 +35,14 @@ All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
* **title**: A short, descriptive title for the change (*)
|
||||
* **blank line after the commit title
|
||||
* **description**: Include additional details on why you made the changes (**).
|
||||
|
||||
(*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!**
|
||||
(**) ⚠️ **Commit description message is mandatory and shouldn't be too long**
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
@@ -66,7 +70,9 @@ Please add a line to the changelog describing your development. The changelog en
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- check your commits
|
||||
- signoff your commits
|
||||
- sign your commits with your key (SSH, GPG etc.)
|
||||
- check your commits (see warnings above)
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
@@ -86,3 +92,11 @@ Make sure that all new features or fixes have corresponding tests. Run the test
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
|
||||
## Contribute to BlockNote
|
||||
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
|
||||
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
|
||||
|
||||
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
|
||||
|
||||
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
|
||||
|
||||
+1
-2
@@ -155,8 +155,7 @@ services:
|
||||
target: frontend-production
|
||||
args:
|
||||
API_ORIGIN: "http://localhost:8071"
|
||||
Y_PROVIDER_URL: "ws://localhost:4444"
|
||||
MEDIA_URL: "http://localhost:8083"
|
||||
PUBLISH_AS_MIT: "false"
|
||||
SW_DEACTIVATED: "true"
|
||||
image: impress:frontend-development
|
||||
ports:
|
||||
|
||||
@@ -38,7 +38,7 @@ dependencies = [
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.8",
|
||||
"django==5.1.9",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
|
||||
@@ -39,6 +39,9 @@ ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
|
||||
ARG SW_DEACTIVATED
|
||||
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
|
||||
|
||||
ARG PUBLISH_AS_MIT
|
||||
ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
|
||||
@@ -452,4 +452,41 @@ test.describe('Doc Editor', () => {
|
||||
const svgBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
expect(svgBuffer.toString()).toContain('Hello svg');
|
||||
});
|
||||
|
||||
test('it checks if callout custom block', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Add a callout block').click();
|
||||
|
||||
const calloutBlock = page
|
||||
.locator('div[data-content-type="callout"]')
|
||||
.first();
|
||||
|
||||
await expect(calloutBlock).toBeVisible();
|
||||
|
||||
await calloutBlock.locator('.inline-content').fill('example text');
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'yellow',
|
||||
);
|
||||
|
||||
const emojiButton = calloutBlock.getByRole('button');
|
||||
await expect(emojiButton).toHaveText('💡');
|
||||
await emojiButton.click();
|
||||
await page.locator('button[aria-label="⚠️"]').click();
|
||||
await expect(emojiButton).toHaveText('⚠️');
|
||||
|
||||
await page.locator('.bn-side-menu > button').last().click();
|
||||
await page.locator('.mantine-Menu-dropdown > button').last().click();
|
||||
await page.locator('.bn-color-picker-dropdown > button').last().click();
|
||||
|
||||
await expect(page.locator('.bn-block').first()).toHaveAttribute(
|
||||
'data-background-color',
|
||||
'pink',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=true
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT=false
|
||||
NEXT_PUBLIC_SW_DEACTIVATED=true
|
||||
|
||||
@@ -48,10 +48,7 @@ const nextConfig = {
|
||||
swDest: '../public/service-worker.js',
|
||||
include: [
|
||||
({ asset }) => {
|
||||
if (asset.name.match(/.*(static).*/)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return !!asset.name.match(/.*(static).*/);
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"@blocknote/react": "0.29.1",
|
||||
"@blocknote/xl-docx-exporter": "0.29.1",
|
||||
"@blocknote/xl-pdf-exporter": "0.29.1",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.4.1",
|
||||
@@ -35,6 +37,7 @@
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.4.1",
|
||||
"emoji-mart": "5.6.0",
|
||||
"i18next": "25.1.1",
|
||||
"i18next-browser-languagedetector": "8.1.0",
|
||||
"idb": "8.0.2",
|
||||
|
||||
@@ -1,17 +1,39 @@
|
||||
/**
|
||||
* Generic interface for representing an API error structure.
|
||||
*
|
||||
* @template T - Optional type of additional data returned with the error.
|
||||
*/
|
||||
interface IAPIError<T = unknown> {
|
||||
/** HTTP status code or API-defined error code */
|
||||
status: number;
|
||||
/** Optional list of error causes (e.g., validation issues) */
|
||||
cause?: string[];
|
||||
/** Optional extra data provided with the error */
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for representing API errors.
|
||||
* Extends the native Error object with additional context such as HTTP status,
|
||||
* causes, and extra data returned by the API.
|
||||
*
|
||||
* @template T - Optional type of the `data` field
|
||||
*/
|
||||
export class APIError<T = unknown> extends Error implements IAPIError<T> {
|
||||
public status: IAPIError['status'];
|
||||
public cause?: IAPIError['cause'];
|
||||
public data?: IAPIError<T>['data'];
|
||||
|
||||
/**
|
||||
* Constructs a new APIError instance.
|
||||
*
|
||||
* @param message - The human-readable error message.
|
||||
* @param status - The HTTP status code or equivalent.
|
||||
* @param cause - (Optional) List of strings describing error causes.
|
||||
* @param data - (Optional) Any additional data returned by the API.
|
||||
*/
|
||||
constructor(message: string, { status, cause, data }: IAPIError<T>) {
|
||||
super(message);
|
||||
|
||||
this.name = 'APIError';
|
||||
this.status = status;
|
||||
this.cause = cause;
|
||||
@@ -19,6 +41,12 @@ export class APIError<T = unknown> extends Error implements IAPIError<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for checking if a value is an instance of APIError.
|
||||
*
|
||||
* @param error - The value to check.
|
||||
* @returns True if the value is an instance of APIError.
|
||||
*/
|
||||
export const isAPIError = (error: unknown): error is APIError => {
|
||||
return error instanceof APIError;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { APIError, isAPIError } from '@/api';
|
||||
|
||||
describe('APIError', () => {
|
||||
it('should correctly instantiate with required fields', () => {
|
||||
const error = new APIError('Something went wrong', { status: 500 });
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error).toBeInstanceOf(APIError);
|
||||
expect(error.message).toBe('Something went wrong');
|
||||
expect(error.status).toBe(500);
|
||||
expect(error.cause).toBeUndefined();
|
||||
expect(error.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly instantiate with all fields', () => {
|
||||
const details = { field: 'email' };
|
||||
const error = new APIError('Validation failed', {
|
||||
status: 400,
|
||||
cause: ['Invalid email format'],
|
||||
data: details,
|
||||
});
|
||||
|
||||
expect(error.name).toBe('APIError');
|
||||
expect(error.status).toBe(400);
|
||||
expect(error.cause).toEqual(['Invalid email format']);
|
||||
expect(error.data).toEqual(details);
|
||||
});
|
||||
|
||||
it('should be detected by isAPIError type guard', () => {
|
||||
const error = new APIError('Unauthorized', { status: 401 });
|
||||
const notAnError = { message: 'Fake error' };
|
||||
|
||||
expect(isAPIError(error)).toBe(true);
|
||||
expect(isAPIError(notAnError)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { baseApiUrl } from '@/api';
|
||||
|
||||
describe('config', () => {
|
||||
it('constructs URL with default version', () => {
|
||||
expect(baseApiUrl()).toBe('http://test.jest/api/v1.0/');
|
||||
});
|
||||
|
||||
it('constructs URL with custom version', () => {
|
||||
expect(baseApiUrl('2.0')).toBe('http://test.jest/api/v2.0/');
|
||||
});
|
||||
|
||||
it('uses env origin if available', () => {
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN = 'https://env.example.com';
|
||||
expect(baseApiUrl('3.0')).toBe('https://env.example.com/api/v3.0/');
|
||||
});
|
||||
});
|
||||
@@ -36,4 +36,13 @@ describe('fetchAPI', () => {
|
||||
|
||||
expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v2.0/some/url');
|
||||
});
|
||||
|
||||
it('removes Content-Type header when withoutContentType is true', async () => {
|
||||
fetchMock.mock('http://test.jest/api/v1.0/some/url', 200);
|
||||
|
||||
await fetchAPI('some/url', { withoutContentType: true });
|
||||
|
||||
const options = fetchMock.lastOptions();
|
||||
expect(options?.headers).not.toHaveProperty('Content-Type');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useAPIInfiniteQuery } from '@/api';
|
||||
|
||||
interface DummyItem {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface DummyResponse {
|
||||
results: DummyItem[];
|
||||
next?: string;
|
||||
}
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient();
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('helpers', () => {
|
||||
it('fetches and paginates correctly', async () => {
|
||||
const mockAPI = jest
|
||||
.fn<Promise<DummyResponse>, [{ page: number; query: string }]>()
|
||||
.mockResolvedValueOnce({
|
||||
results: [{ id: 1 }],
|
||||
next: 'url?page=2',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
results: [{ id: 2 }],
|
||||
next: undefined,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAPIInfiniteQuery('test-key', mockAPI, { query: 'test' }),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Wait for first page
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.pages[0].results[0].id).toBe(1);
|
||||
});
|
||||
|
||||
// Fetch next page
|
||||
await result.current.fetchNextPage();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.pages.length).toBe(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.pages[1].results[0].id).toBe(2);
|
||||
});
|
||||
|
||||
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 1 });
|
||||
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { errorCauses, getCSRFToken } from '@/api';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('errorCauses', () => {
|
||||
const createMockResponse = (jsonData: any, status = 400): Response => {
|
||||
return {
|
||||
status,
|
||||
json: () => jsonData,
|
||||
} as unknown as Response;
|
||||
};
|
||||
|
||||
it('parses multiple string causes from error body', async () => {
|
||||
const mockResponse = createMockResponse(
|
||||
{
|
||||
field: ['error message 1', 'error message 2'],
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const result = await errorCauses(mockResponse, { context: 'login' });
|
||||
|
||||
expect(result.status).toBe(400);
|
||||
expect(result.cause).toEqual(['error message 1', 'error message 2']);
|
||||
expect(result.data).toEqual({ context: 'login' });
|
||||
});
|
||||
|
||||
it('returns undefined causes if no JSON body', async () => {
|
||||
const mockResponse = createMockResponse(null, 500);
|
||||
|
||||
const result = await errorCauses(mockResponse);
|
||||
|
||||
expect(result.status).toBe(500);
|
||||
expect(result.cause).toBeUndefined();
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCSRFToken', () => {
|
||||
it('extracts csrftoken from document.cookie', () => {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: 'sessionid=xyz; csrftoken=abc123; theme=dark',
|
||||
});
|
||||
|
||||
expect(getCSRFToken()).toBe('abc123');
|
||||
});
|
||||
|
||||
it('returns undefined if csrftoken is not present', () => {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: 'sessionid=xyz; theme=dark',
|
||||
});
|
||||
|
||||
expect(getCSRFToken()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,22 @@
|
||||
/**
|
||||
* Returns the base URL for the backend API.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Uses NEXT_PUBLIC_API_ORIGIN from environment variables if defined.
|
||||
* 2. Falls back to the browser's window.location.origin if in a browser environment.
|
||||
* 3. Defaults to an empty string if executed in a non-browser environment without the env variable.
|
||||
*
|
||||
* @returns The backend base URL as a string.
|
||||
*/
|
||||
export const backendUrl = () =>
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
|
||||
/**
|
||||
* Constructs the full base API URL, including the versioned path (e.g., `/api/v1.0/`).
|
||||
*
|
||||
* @param apiVersion - The version of the API (defaults to '1.0').
|
||||
* @returns The full versioned API base URL as a string.
|
||||
*/
|
||||
export const baseApiUrl = (apiVersion: string = '1.0') =>
|
||||
`${backendUrl()}/api/v${apiVersion}/`;
|
||||
|
||||
@@ -23,11 +23,9 @@ export const fetchAPI = async (
|
||||
delete headers?.['Content-Type' as keyof typeof headers];
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
return await fetch(apiUrl, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -22,9 +22,17 @@ export type DefinedInitialDataInfiniteOptionsAPI<
|
||||
>;
|
||||
|
||||
/**
|
||||
* @param param Used for infinite scroll pagination
|
||||
* @param queryConfig
|
||||
* @returns
|
||||
* Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests.
|
||||
*
|
||||
* @template T - Type of the request parameters.
|
||||
* @template Q - Type of the API response, which must include an optional `next` field for pagination.
|
||||
*
|
||||
* @param {string} key - Unique key to identify the query in the cache.
|
||||
* @param {(props: T & { page: number }) => Promise<Q>} api - Function that fetches paginated data from the API. It receives the params merged with a page number.
|
||||
* @param {T} param - Static parameters to send with every API request (excluding the page number).
|
||||
* @param {DefinedInitialDataInfiniteOptionsAPI<Q>} [queryConfig] - Optional configuration passed to `useInfiniteQuery` (e.g., stale time, cache time).
|
||||
*
|
||||
* @returns Return value of `useInfiniteQuery`, including data, loading state, fetchNextPage, etc.
|
||||
*/
|
||||
export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
|
||||
key: string,
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
/**
|
||||
* Generic interface representing a paginated API response.
|
||||
*
|
||||
* Commonly used for endpoints that return list results with pagination metadata.
|
||||
*
|
||||
* @template T - The type of items in the `results` array.
|
||||
*/
|
||||
export interface APIList<T> {
|
||||
/** Total number of items across all pages */
|
||||
count: number;
|
||||
|
||||
/** URL to the next page of results, if available (can be null or undefined) */
|
||||
next?: string | null;
|
||||
|
||||
/** URL to the previous page of results, if available (can be null or undefined) */
|
||||
previous?: string | null;
|
||||
|
||||
/** The list of items for the current page */
|
||||
results: T[];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
/**
|
||||
* Extracts error information from an HTTP `Response` object.
|
||||
*
|
||||
* This is typically used to parse structured error responses from an API
|
||||
* and normalize them into a consistent format with `status`, `cause`, and optional `data`.
|
||||
*
|
||||
* @param response - The HTTP response object from `fetch()`.
|
||||
* @param data - Optional custom data to include with the error output.
|
||||
* @returns An object containing:
|
||||
* - `status`: HTTP status code from the response
|
||||
* - `cause`: A flattened list of error messages, or undefined if no body
|
||||
* - `data`: The optional data passed in
|
||||
*/
|
||||
export const errorCauses = async (response: Response, data?: unknown) => {
|
||||
const errorsBody = (await response.json()) as Record<
|
||||
string,
|
||||
@@ -18,7 +31,11 @@ export const errorCauses = async (response: Response, data?: unknown) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the CSRF token from the document's cookies.
|
||||
* Retrieves the CSRF token from the browser's cookies.
|
||||
*
|
||||
* Assumes the CSRF token is stored as a cookie named "csrftoken".
|
||||
*
|
||||
* @returns The CSRF token string if found, otherwise `undefined`.
|
||||
*/
|
||||
export function getCSRFToken() {
|
||||
return document.cookie
|
||||
|
||||
@@ -35,8 +35,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanupResizeListener = initializeResizeListener();
|
||||
return cleanupResizeListener;
|
||||
return initializeResizeListener();
|
||||
}, [initializeResizeListener]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -53,13 +53,11 @@ export function useConfig() {
|
||||
const cachedData = getCachedTranslation();
|
||||
const oneHour = 1000 * 60 * 60;
|
||||
|
||||
const response = useQuery<ConfigResponse, APIError, ConfigResponse>({
|
||||
return useQuery<ConfigResponse, APIError, ConfigResponse>({
|
||||
queryKey: [KEY_CONFIG],
|
||||
queryFn: () => getConfig(),
|
||||
initialData: cachedData,
|
||||
staleTime: oneHour,
|
||||
initialDataUpdatedAt: Date.now() - oneHour, // Force initial data to be considered stale
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ declare module '*.svg?url' {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_API_ORIGIN?: string;
|
||||
NEXT_PUBLIC_PUBLISH_AS_MIT?: string;
|
||||
NEXT_PUBLIC_SW_DEACTIVATED?: string;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -27,12 +27,13 @@ import { randomColor } from '../utils';
|
||||
|
||||
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
|
||||
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
|
||||
import { DividerBlock } from './custom-blocks';
|
||||
import { CalloutBlock, DividerBlock } from './custom-blocks';
|
||||
|
||||
export const blockNoteSchema = withPageBreak(
|
||||
BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
...defaultBlockSpecs,
|
||||
callout: CalloutBlock,
|
||||
divider: DividerBlock,
|
||||
},
|
||||
}),
|
||||
|
||||
+5
-1
@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DocsBlockSchema } from '../types';
|
||||
|
||||
import { getDividerReactSlashMenuItems } from './custom-blocks';
|
||||
import {
|
||||
getCalloutReactSlashMenuItems,
|
||||
getDividerReactSlashMenuItems,
|
||||
} from './custom-blocks';
|
||||
|
||||
export const BlockNoteSuggestionMenu = () => {
|
||||
const editor = useBlockNoteEditor<DocsBlockSchema>();
|
||||
@@ -25,6 +28,7 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
combineByGroup(
|
||||
getDefaultReactSlashMenuItems(editor),
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
),
|
||||
query,
|
||||
|
||||
+6
-1
@@ -6,9 +6,12 @@ import {
|
||||
useDictionary,
|
||||
} from '@blocknote/react';
|
||||
import React, { JSX, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useConfig } from '@/core/config/api';
|
||||
|
||||
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
|
||||
|
||||
import { AIGroupButton } from './AIButton';
|
||||
import { FileDownloadButton } from './FileDownloadButton';
|
||||
import { MarkdownButton } from './MarkdownButton';
|
||||
@@ -18,11 +21,13 @@ export const BlockNoteToolbar = () => {
|
||||
const dict = useDictionary();
|
||||
const [confirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
|
||||
const { t } = useTranslation();
|
||||
const { data: conf } = useConfig();
|
||||
|
||||
const toolbarItems = useMemo(() => {
|
||||
const toolbarItems = getFormattingToolbarItems([
|
||||
...blockTypeSelectItems(dict),
|
||||
getCalloutFormattingToolbarItems(t),
|
||||
]);
|
||||
const fileDownloadButtonIndex = toolbarItems.findIndex(
|
||||
(item) =>
|
||||
@@ -46,7 +51,7 @@ export const BlockNoteToolbar = () => {
|
||||
}
|
||||
|
||||
return toolbarItems as JSX.Element[];
|
||||
}, [dict]);
|
||||
}, [dict, t]);
|
||||
|
||||
const formattingToolbar = useCallback(() => {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import data from '@emoji-mart/data';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
|
||||
interface EmojiPickerProps {
|
||||
categories: string[];
|
||||
custom: {
|
||||
name: string;
|
||||
id: string;
|
||||
emojis: string[];
|
||||
}[];
|
||||
onClickOutside: () => void;
|
||||
onEmojiSelect: ({ native }: { native: string }) => void;
|
||||
}
|
||||
|
||||
export const EmojiPicker = ({
|
||||
categories,
|
||||
custom,
|
||||
onClickOutside,
|
||||
onEmojiSelect,
|
||||
}: EmojiPickerProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
|
||||
<Picker
|
||||
categories={categories}
|
||||
custom={custom}
|
||||
data={data}
|
||||
locale={i18n.resolvedLanguage}
|
||||
navPosition="none"
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
previewPosition="none"
|
||||
skinTonePosition="none"
|
||||
theme="light"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
|
||||
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon } from '@/components';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
import { EmojiPicker } from '../EmojiPicker';
|
||||
|
||||
const calloutCustom = [
|
||||
{
|
||||
name: 'Callout',
|
||||
id: 'callout',
|
||||
emojis: [
|
||||
'bulb',
|
||||
'point_right',
|
||||
'point_up',
|
||||
'ok_hand',
|
||||
'key',
|
||||
'construction',
|
||||
'warning',
|
||||
'fire',
|
||||
'pushpin',
|
||||
'scissors',
|
||||
'question',
|
||||
'no_entry',
|
||||
'no_entry_sign',
|
||||
'alarm_clock',
|
||||
'phone',
|
||||
'rotating_light',
|
||||
'recycle',
|
||||
'white_check_mark',
|
||||
'lock',
|
||||
'paperclip',
|
||||
'book',
|
||||
'speaking_head_in_silhouette',
|
||||
'arrow_right',
|
||||
'loudspeaker',
|
||||
'hammer_and_wrench',
|
||||
'gear',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const calloutCategories = [
|
||||
'callout',
|
||||
'people',
|
||||
'nature',
|
||||
'foods',
|
||||
'activity',
|
||||
'places',
|
||||
'flags',
|
||||
'objects',
|
||||
'symbols',
|
||||
];
|
||||
|
||||
export const CalloutBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'callout',
|
||||
propSchema: {
|
||||
textAlignment: defaultProps.textAlignment,
|
||||
backgroundColor: defaultProps.backgroundColor,
|
||||
emoji: { default: '💡' },
|
||||
},
|
||||
content: 'inline',
|
||||
},
|
||||
{
|
||||
render: ({ block, editor, contentRef }) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
};
|
||||
|
||||
const onClickOutside = () => setOpenEmojiPicker(false);
|
||||
|
||||
const onEmojiSelect = ({ native }: { native: string }) => {
|
||||
editor.updateBlock(block, { props: { emoji: native } });
|
||||
setOpenEmojiPicker(false);
|
||||
};
|
||||
|
||||
// Temporary: sets a yellow background color to a callout block when added by
|
||||
// the user, while keeping the colors menu on the drag handler usable for
|
||||
// this custom block.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!block.content.length &&
|
||||
block.props.backgroundColor === 'default'
|
||||
) {
|
||||
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
|
||||
}
|
||||
}, [block, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
$padding="1rem"
|
||||
$gap="0.625rem"
|
||||
style={{
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
<BoxButton
|
||||
contentEditable={false}
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
$align="center"
|
||||
$height="28px"
|
||||
$width="28px"
|
||||
$radius="4px"
|
||||
>
|
||||
{block.props.emoji}
|
||||
</BoxButton>
|
||||
|
||||
{openEmojiPicker && (
|
||||
<EmojiPicker
|
||||
categories={calloutCategories}
|
||||
custom={calloutCustom}
|
||||
onClickOutside={onClickOutside}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
)}
|
||||
<Box as="p" className="inline-content" ref={contentRef} />
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getCalloutReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('Callout'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, {
|
||||
type: 'callout',
|
||||
});
|
||||
},
|
||||
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
|
||||
group,
|
||||
icon: <Icon iconName="lightbulb" $size="18px" />,
|
||||
subtext: t('Add a callout block'),
|
||||
},
|
||||
];
|
||||
|
||||
export const getCalloutFormattingToolbarItems = (
|
||||
t: TFunction<'translation', undefined>,
|
||||
): BlockTypeSelectItem => ({
|
||||
name: t('Callout'),
|
||||
type: 'callout',
|
||||
icon: () => <Icon iconName="lightbulb" $size="16px" />,
|
||||
isSelected: (block) => block.type === 'callout',
|
||||
});
|
||||
+1
@@ -1 +1,2 @@
|
||||
export * from './CalloutBlock';
|
||||
export * from './DividerBlock';
|
||||
|
||||
@@ -31,7 +31,7 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
|
||||
_updatedDoc: Y.Doc,
|
||||
transaction: Y.Transaction,
|
||||
) => {
|
||||
setIsLocalChange(transaction.local ? true : false);
|
||||
setIsLocalChange(transaction.local);
|
||||
};
|
||||
|
||||
yDoc.on('update', onUpdate);
|
||||
|
||||
@@ -61,6 +61,23 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callout, Paragraph and Heading blocks
|
||||
*/
|
||||
.bn-block {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-outer {
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='paragraph'],
|
||||
.bn-block-content[data-content-type='heading'] {
|
||||
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Paragraph, TextRun } from 'docx';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
import { docxBlockPropsToStyles } from '../utils';
|
||||
|
||||
export const blockMappingCalloutDocx: DocsExporterDocx['mappings']['blockMapping']['callout'] =
|
||||
(block, exporter) => {
|
||||
return new Paragraph({
|
||||
...docxBlockPropsToStyles(block.props, exporter.options.colors),
|
||||
spacing: { before: 10, after: 10 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: ' ',
|
||||
break: 1,
|
||||
}),
|
||||
new TextRun(' ' + block.props.emoji + ' '),
|
||||
...exporter.transformInlineContent(block.content),
|
||||
new TextRun({
|
||||
text: ' ',
|
||||
break: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: 8,
|
||||
gap: 4,
|
||||
},
|
||||
emoji: {
|
||||
fontSize: 16,
|
||||
},
|
||||
text: {
|
||||
maxWidth: '94%',
|
||||
paddingTop: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export const blockMappingCalloutPDF: DocsExporterPDF['mappings']['blockMapping']['callout'] =
|
||||
(block, exporter) => {
|
||||
return (
|
||||
<View wrap={false} style={styles.wrapper}>
|
||||
<Text debug={false} style={styles.emoji}>
|
||||
{block.props.emoji}
|
||||
</Text>
|
||||
<Text debug={false} style={styles.text}>
|
||||
{' '}
|
||||
{exporter.transformInlineContent(block.content)}{' '}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './calloutDocx';
|
||||
export * from './calloutPDF';
|
||||
export * from './dividerDocx';
|
||||
export * from './dividerPDF';
|
||||
export * from './headingPDF';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
|
||||
|
||||
import {
|
||||
blockMappingCalloutDocx,
|
||||
blockMappingDividerDocx,
|
||||
blockMappingImageDocx,
|
||||
blockMappingQuoteDocx,
|
||||
@@ -11,6 +12,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
...docxDefaultSchemaMappings,
|
||||
blockMapping: {
|
||||
...docxDefaultSchemaMappings.blockMapping,
|
||||
callout: blockMappingCalloutDocx,
|
||||
divider: blockMappingDividerDocx,
|
||||
quote: blockMappingQuoteDocx,
|
||||
image: blockMappingImageDocx,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
|
||||
|
||||
import {
|
||||
blockMappingCalloutPDF,
|
||||
blockMappingDividerPDF,
|
||||
blockMappingHeadingPDF,
|
||||
blockMappingImagePDF,
|
||||
@@ -14,6 +15,7 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
|
||||
...pdfDefaultSchemaMappings,
|
||||
blockMapping: {
|
||||
...pdfDefaultSchemaMappings.blockMapping,
|
||||
callout: blockMappingCalloutPDF,
|
||||
heading: blockMappingHeadingPDF,
|
||||
image: blockMappingImagePDF,
|
||||
paragraph: blockMappingParagraphPDF,
|
||||
|
||||
@@ -19,9 +19,16 @@ export function downloadFile(blob: Blob, filename: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SVG to PNG
|
||||
* @param svgText - The SVG text to convert
|
||||
* @returns The PNG data URL
|
||||
* Converts an SVG string into a PNG image and returns it as a data URL.
|
||||
*
|
||||
* This function creates a canvas, parses the SVG, calculates the appropriate height
|
||||
* to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
|
||||
*
|
||||
* @param {string} svgText - The raw SVG markup to convert.
|
||||
* @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio).
|
||||
* @returns {Promise<string>} A Promise that resolves to a PNG image encoded as a base64 data URL.
|
||||
*
|
||||
* @throws Will throw an error if the canvas context cannot be initialized.
|
||||
*/
|
||||
export async function convertSvgToPng(svgText: string, width: number) {
|
||||
// Create a canvas and render the SVG onto it
|
||||
|
||||
+4
-1
@@ -42,6 +42,7 @@ const doc = {
|
||||
|
||||
beforeEach(() => {
|
||||
Analytics.clearAnalytics();
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||
});
|
||||
|
||||
describe('DocToolBox "Copy as HTML" option', () => {
|
||||
@@ -51,7 +52,9 @@ describe('DocToolBox "Copy as HTML" option', () => {
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
const optionsButton = screen.getByLabelText('Open the document options');
|
||||
const optionsButton = await screen.findByLabelText(
|
||||
'Open the document options',
|
||||
);
|
||||
await userEvent.click(optionsButton);
|
||||
expect(await screen.findByText('Copy as HTML')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
import { DocToolBox } from '../components/DocToolBox';
|
||||
|
||||
const doc = {
|
||||
nb_accesses: 1,
|
||||
abilities: {
|
||||
versions_list: true,
|
||||
destroy: true,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('@/features/docs/doc-export/', () => ({
|
||||
ModalExport: () => <span>ModalExport</span>,
|
||||
}));
|
||||
|
||||
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
|
||||
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
expect(await screen.findByText('download')).toBeInTheDocument();
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { AppWrapper } from '@/tests/utils';
|
||||
|
||||
const doc = {
|
||||
nb_accesses: 1,
|
||||
abilities: {
|
||||
versions_list: true,
|
||||
destroy: true,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('@/features/docs/doc-export/', () => ({
|
||||
ModalExport: () => <span>ModalExport</span>,
|
||||
}));
|
||||
|
||||
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => {
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
|
||||
|
||||
const { DocToolBox } = await import('../components/DocToolBox');
|
||||
|
||||
render(<DocToolBox doc={doc as any} />, {
|
||||
wrapper: AppWrapper,
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText('download')).not.toBeInTheDocument();
|
||||
},
|
||||
{
|
||||
timeout: 1000,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,170 +1,47 @@
|
||||
import {
|
||||
Button,
|
||||
VariantType,
|
||||
useModal,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { Button, useModal } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import {
|
||||
Box,
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
Icon,
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useEditorStore } from '@/docs/doc-editor/';
|
||||
import { ModalExport } from '@/docs/doc-export/';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
ModalRemoveDoc,
|
||||
useCopyDocLink,
|
||||
useCreateFavoriteDoc,
|
||||
useDeleteFavoriteDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { DocShareModal } from '@/docs/doc-share';
|
||||
import {
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
ModalSelectVersion,
|
||||
} from '@/docs/doc-versioning';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
interface DocToolBoxProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
const DocToolBoxLicence = dynamic(() =>
|
||||
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
|
||||
? import('./DocToolBoxLicenceAGPL').then((mod) => mod.DocToolBoxLicenceAGPL)
|
||||
: import('./DocToolBoxLicenceMIT').then((mod) => mod.DocToolBoxLicenceMIT),
|
||||
);
|
||||
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
|
||||
const selectHistoryModal = useModal();
|
||||
const modalHistory = useModal();
|
||||
const modalShare = useModal();
|
||||
|
||||
const { isSmallMobile, isDesktop } = useResponsiveStore();
|
||||
const { editor } = useEditorStore();
|
||||
const { toast } = useToastProvider();
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
const { isFeatureFlagActivated } = useAnalytics();
|
||||
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
const makeFavoriteDoc = useCreateFavoriteDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
...(isSmallMobile
|
||||
? [
|
||||
{
|
||||
label: t('Share'),
|
||||
icon: 'group',
|
||||
callback: modalShare.open,
|
||||
},
|
||||
{
|
||||
label: t('Export'),
|
||||
icon: 'download',
|
||||
callback: () => {
|
||||
setIsModalExportOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Copy link'),
|
||||
icon: 'add_link',
|
||||
callback: copyDocLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: doc.is_favorite ? t('Unpin') : t('Pin'),
|
||||
icon: 'push_pin',
|
||||
callback: () => {
|
||||
if (doc.is_favorite) {
|
||||
removeFavoriteDoc.mutate({ id: doc.id });
|
||||
} else {
|
||||
makeFavoriteDoc.mutate({ id: doc.id });
|
||||
}
|
||||
},
|
||||
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
|
||||
},
|
||||
{
|
||||
label: t('Version history'),
|
||||
icon: 'history',
|
||||
disabled: !doc.abilities.versions_list,
|
||||
callback: () => {
|
||||
selectHistoryModal.open();
|
||||
},
|
||||
show: isDesktop,
|
||||
},
|
||||
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
||||
icon: 'content_copy',
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('markdown');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'HTML' }),
|
||||
icon: 'content_copy',
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('html');
|
||||
},
|
||||
show: isFeatureFlagActivated('CopyAsHTML'),
|
||||
},
|
||||
{
|
||||
label: t('Delete document'),
|
||||
icon: 'delete',
|
||||
disabled: !doc.abilities.destroy,
|
||||
callback: () => {
|
||||
setIsModalRemoveOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const copyCurrentEditorToClipboard = async (
|
||||
asFormat: 'html' | 'markdown',
|
||||
) => {
|
||||
if (!editor) {
|
||||
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorContentFormatted =
|
||||
asFormat === 'html'
|
||||
? await editor.blocksToHTMLLossy()
|
||||
: await editor.blocksToMarkdownLossy();
|
||||
await navigator.clipboard.writeText(editorContentFormatted);
|
||||
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
const { isSmallMobile } = useResponsiveStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (selectHistoryModal.isOpen) {
|
||||
if (modalHistory.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC_VERSIONS],
|
||||
});
|
||||
}, [selectHistoryModal.isOpen, queryClient]);
|
||||
}, [modalHistory.isOpen, queryClient]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -222,55 +99,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isSmallMobile && (
|
||||
<Button
|
||||
color="tertiary-text"
|
||||
icon={
|
||||
<Icon iconName="download" $theme="primary" $variation="800" />
|
||||
}
|
||||
onClick={() => {
|
||||
setIsModalExportOpen(true);
|
||||
}}
|
||||
size={isSmallMobile ? 'small' : 'medium'}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu options={options}>
|
||||
<IconOptions
|
||||
isHorizontal
|
||||
$theme="primary"
|
||||
$padding={{ all: 'xs' }}
|
||||
$css={css`
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
}
|
||||
${isSmallMobile
|
||||
? css`
|
||||
padding: 10px;
|
||||
border: 1px solid ${colorsTokens['greyscale-300']};
|
||||
`
|
||||
: ''}
|
||||
`}
|
||||
aria-label={t('Open the document options')}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</Box>
|
||||
|
||||
{modalShare.isOpen && (
|
||||
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
|
||||
)}
|
||||
{isModalExportOpen && (
|
||||
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
|
||||
)}
|
||||
{isModalRemoveOpen && (
|
||||
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
|
||||
)}
|
||||
{selectHistoryModal.isOpen && (
|
||||
<ModalSelectVersion
|
||||
onClose={() => selectHistoryModal.close()}
|
||||
<DocToolBoxLicence
|
||||
doc={doc}
|
||||
modalHistory={modalHistory}
|
||||
modalShare={modalShare}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import { Button, useModal } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
Icon,
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { ModalExport } from '@/docs/doc-export/';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
ModalRemoveDoc,
|
||||
useCopyDocLink,
|
||||
useCreateFavoriteDoc,
|
||||
useDeleteFavoriteDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import {
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
ModalSelectVersion,
|
||||
} from '@/docs/doc-versioning';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { DocShareModal } from '../../doc-share';
|
||||
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
||||
|
||||
type ModalType = ReturnType<typeof useModal>;
|
||||
|
||||
interface DocToolBoxLicenceProps {
|
||||
doc: Doc;
|
||||
modalHistory: ModalType;
|
||||
modalShare: ModalType;
|
||||
}
|
||||
|
||||
export const DocToolBoxLicenceAGPL = ({
|
||||
doc,
|
||||
modalHistory,
|
||||
modalShare,
|
||||
}: DocToolBoxLicenceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
|
||||
|
||||
const { isSmallMobile, isDesktop } = useResponsiveStore();
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
const { isFeatureFlagActivated } = useAnalytics();
|
||||
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
const makeFavoriteDoc = useCreateFavoriteDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
...(isSmallMobile
|
||||
? [
|
||||
{
|
||||
label: t('Share'),
|
||||
icon: 'group',
|
||||
callback: modalShare.open,
|
||||
},
|
||||
{
|
||||
label: t('Export'),
|
||||
icon: 'download',
|
||||
callback: () => {
|
||||
setIsModalExportOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Copy link'),
|
||||
icon: 'add_link',
|
||||
callback: copyDocLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: doc.is_favorite ? t('Unpin') : t('Pin'),
|
||||
icon: 'push_pin',
|
||||
callback: () => {
|
||||
if (doc.is_favorite) {
|
||||
removeFavoriteDoc.mutate({ id: doc.id });
|
||||
} else {
|
||||
makeFavoriteDoc.mutate({ id: doc.id });
|
||||
}
|
||||
},
|
||||
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
|
||||
},
|
||||
{
|
||||
label: t('Version history'),
|
||||
icon: 'history',
|
||||
disabled: !doc.abilities.versions_list,
|
||||
callback: () => {
|
||||
modalHistory.open();
|
||||
},
|
||||
show: isDesktop,
|
||||
},
|
||||
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
||||
icon: 'content_copy',
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('markdown');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'HTML' }),
|
||||
icon: 'content_copy',
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('html');
|
||||
},
|
||||
show: isFeatureFlagActivated('CopyAsHTML'),
|
||||
},
|
||||
{
|
||||
label: t('Delete document'),
|
||||
icon: 'delete',
|
||||
disabled: !doc.abilities.destroy,
|
||||
callback: () => {
|
||||
setIsModalRemoveOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (modalHistory.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC_VERSIONS],
|
||||
});
|
||||
}, [modalHistory.isOpen, queryClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isSmallMobile && (
|
||||
<Button
|
||||
color="tertiary-text"
|
||||
icon={<Icon iconName="download" $theme="primary" $variation="800" />}
|
||||
onClick={() => {
|
||||
setIsModalExportOpen(true);
|
||||
}}
|
||||
size={isSmallMobile ? 'small' : 'medium'}
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu options={options}>
|
||||
<IconOptions
|
||||
isHorizontal
|
||||
$theme="primary"
|
||||
$padding={{ all: 'xs' }}
|
||||
$css={css`
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
}
|
||||
${isSmallMobile
|
||||
? css`
|
||||
padding: 10px;
|
||||
border: 1px solid ${colorsTokens['greyscale-300']};
|
||||
`
|
||||
: ''}
|
||||
`}
|
||||
aria-label={t('Open the document options')}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
{modalShare.isOpen && (
|
||||
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
|
||||
)}
|
||||
{isModalExportOpen && (
|
||||
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
|
||||
)}
|
||||
{isModalRemoveOpen && (
|
||||
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
|
||||
)}
|
||||
{modalHistory.isOpen && (
|
||||
<ModalSelectVersion onClose={() => modalHistory.close()} doc={doc} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { DropdownMenu, DropdownMenuOption, IconOptions } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
ModalRemoveDoc,
|
||||
useCopyDocLink,
|
||||
useCreateFavoriteDoc,
|
||||
useDeleteFavoriteDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import {
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
ModalSelectVersion,
|
||||
} from '@/docs/doc-versioning';
|
||||
import { useAnalytics } from '@/libs';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { DocShareModal } from '../../doc-share';
|
||||
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
|
||||
|
||||
type ModalType = ReturnType<typeof useModal>;
|
||||
|
||||
interface DocToolBoxLicenceProps {
|
||||
doc: Doc;
|
||||
modalHistory: ModalType;
|
||||
modalShare: ModalType;
|
||||
}
|
||||
|
||||
export const DocToolBoxLicenceMIT = ({
|
||||
doc,
|
||||
modalHistory,
|
||||
modalShare,
|
||||
}: DocToolBoxLicenceProps) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
|
||||
|
||||
const { isSmallMobile, isDesktop } = useResponsiveStore();
|
||||
const copyDocLink = useCopyDocLink(doc.id);
|
||||
const { isFeatureFlagActivated } = useAnalytics();
|
||||
const removeFavoriteDoc = useDeleteFavoriteDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
const makeFavoriteDoc = useCreateFavoriteDoc({
|
||||
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
|
||||
});
|
||||
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
...(isSmallMobile
|
||||
? [
|
||||
{
|
||||
label: t('Share'),
|
||||
icon: 'group',
|
||||
callback: modalShare.open,
|
||||
},
|
||||
{
|
||||
label: t('Copy link'),
|
||||
icon: 'add_link',
|
||||
callback: copyDocLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: doc.is_favorite ? t('Unpin') : t('Pin'),
|
||||
icon: 'push_pin',
|
||||
callback: () => {
|
||||
if (doc.is_favorite) {
|
||||
removeFavoriteDoc.mutate({ id: doc.id });
|
||||
} else {
|
||||
makeFavoriteDoc.mutate({ id: doc.id });
|
||||
}
|
||||
},
|
||||
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
|
||||
},
|
||||
{
|
||||
label: t('Version history'),
|
||||
icon: 'history',
|
||||
disabled: !doc.abilities.versions_list,
|
||||
callback: () => {
|
||||
modalHistory.open();
|
||||
},
|
||||
show: isDesktop,
|
||||
},
|
||||
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'Markdown' }),
|
||||
icon: 'content_copy',
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('markdown');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('Copy as {{format}}', { format: 'HTML' }),
|
||||
icon: 'content_copy',
|
||||
callback: () => {
|
||||
void copyCurrentEditorToClipboard('html');
|
||||
},
|
||||
show: isFeatureFlagActivated('CopyAsHTML'),
|
||||
},
|
||||
{
|
||||
label: t('Delete document'),
|
||||
icon: 'delete',
|
||||
disabled: !doc.abilities.destroy,
|
||||
callback: () => {
|
||||
setIsModalRemoveOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (modalHistory.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC_VERSIONS],
|
||||
});
|
||||
}, [modalHistory.isOpen, queryClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu options={options}>
|
||||
<IconOptions
|
||||
isHorizontal
|
||||
$theme="primary"
|
||||
$padding={{ all: 'xs' }}
|
||||
$css={css`
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: ${colorsTokens['greyscale-100']};
|
||||
}
|
||||
${isSmallMobile
|
||||
? css`
|
||||
padding: 10px;
|
||||
border: 1px solid ${colorsTokens['greyscale-300']};
|
||||
`
|
||||
: ''}
|
||||
`}
|
||||
aria-label={t('Open the document options')}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
{modalShare.isOpen && (
|
||||
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
|
||||
)}
|
||||
{isModalRemoveOpen && (
|
||||
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
|
||||
)}
|
||||
{modalHistory.isOpen && (
|
||||
<ModalSelectVersion onClose={() => modalHistory.close()} doc={doc} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useEditorStore } from '../../doc-editor';
|
||||
|
||||
export const useCopyCurrentEditorToClipboard = () => {
|
||||
const { editor } = useEditorStore();
|
||||
const { toast } = useToastProvider();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return async (asFormat: 'html' | 'markdown') => {
|
||||
if (!editor) {
|
||||
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorContentFormatted =
|
||||
asFormat === 'html'
|
||||
? await editor.blocksToHTMLLossy()
|
||||
: await editor.blocksToMarkdownLossy();
|
||||
await navigator.clipboard.writeText(editorContentFormatted);
|
||||
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -38,11 +38,15 @@ setCacheNameDetails({
|
||||
});
|
||||
|
||||
/**
|
||||
* In development, use NetworkFirst strategy, in production use CacheFirst strategy
|
||||
* We will be able to test the application in development without having to clear the cache.
|
||||
* @param url
|
||||
* @param options
|
||||
* @returns strategy
|
||||
* Chooses the appropriate caching strategy based on the environment and request context.
|
||||
*
|
||||
* - In **development**, or for **API requests**, or **HTML pages**, it returns a `NetworkFirst` strategy
|
||||
* to prioritize fresh responses and ease debugging without needing to clear caches.
|
||||
* - In **production** (for non-API, non-HTML content), it returns a `CacheFirst` strategy
|
||||
* to favor performance and offline access.
|
||||
*
|
||||
* @param {NetworkFirstOptions | StrategyOptions} [options] - Configuration options for the caching strategy.
|
||||
* @returns {NetworkFirst | CacheFirst} The selected Workbox strategy instance.
|
||||
*/
|
||||
const getStrategy = (
|
||||
options?: NetworkFirstOptions | StrategyOptions,
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"Accessible to anyone": "Für alle zugänglich",
|
||||
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
|
||||
"Add": "Hinzufügen",
|
||||
"Add a callout block": "Hebt schrift hervor",
|
||||
"Add a horizontal line": "Waagerechte Linie einfügen",
|
||||
"Address:": "Anschrift:",
|
||||
"Administrator": "Administrator",
|
||||
@@ -67,6 +68,7 @@
|
||||
"Available soon": "Bald verfügbar",
|
||||
"Banner image": "Bannerbild",
|
||||
"Beautify": "Verschönern",
|
||||
"Callout": "Hervorhebung",
|
||||
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
|
||||
"Cancel": "Abbrechen",
|
||||
"Close the modal": "Pop up schliessen",
|
||||
@@ -284,6 +286,7 @@
|
||||
"Accessible to anyone": "Accesible para todos",
|
||||
"Accessible to authenticated users": "Accesible a usuarios autenticados",
|
||||
"Add": "Añadir",
|
||||
"Add a callout block": "Resaltar el texto para que destaque",
|
||||
"Add a horizontal line": "Añadir una línea horizontal",
|
||||
"Address:": "Dirección:",
|
||||
"Administrator": "Administrador",
|
||||
@@ -298,6 +301,7 @@
|
||||
"Available soon": "Próximamente disponible",
|
||||
"Banner image": "Imagen de portada",
|
||||
"Beautify": "Embellecer",
|
||||
"Callout": "Destacado",
|
||||
"Can't load this page, please check your internet connection.": "No se puede cargar esta página, por favor compruebe su conexión a Internet.",
|
||||
"Cancel": "Cancelar",
|
||||
"Close the modal": "Cerrar modal",
|
||||
@@ -508,6 +512,7 @@
|
||||
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
|
||||
"Add": "Ajouter",
|
||||
"Add a horizontal line": "Ajouter une ligne horizontale",
|
||||
"Add a callout block": "Faites ressortir du texte pour le mettre en évidence",
|
||||
"Address:": "Adresse :",
|
||||
"Administrator": "Administrateur",
|
||||
"All docs": "Tous les documents",
|
||||
@@ -521,6 +526,7 @@
|
||||
"Available soon": "Disponible prochainement",
|
||||
"Banner image": "Image de la bannière",
|
||||
"Beautify": "Embellir",
|
||||
"Callout": "Encadré",
|
||||
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
|
||||
"Cancel": "Annuler",
|
||||
"Close the modal": "Fermer la modale",
|
||||
@@ -914,6 +920,7 @@
|
||||
"Accessible to anyone": "Toegankelijk voor iedereen",
|
||||
"Accessible to authenticated users": "Toegankelijk voor geauthentiseerde gebruikers",
|
||||
"Add": "Voeg toe",
|
||||
"Add a callout block": "Laat je tekst opvallen",
|
||||
"Add a horizontal line": "Voeg horizontale lijn toe",
|
||||
"Address:": "Adres:",
|
||||
"Administrator": "Administrator",
|
||||
@@ -928,6 +935,7 @@
|
||||
"Available soon": "Binnenkort beschikbaar",
|
||||
"Banner image": "Banner afbeelding",
|
||||
"Beautify": "Maak mooier",
|
||||
"Callout": "Benadrukken",
|
||||
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
|
||||
"Cancel": "Breek af",
|
||||
"Close the modal": "Sluit het venster",
|
||||
|
||||
@@ -25,14 +25,10 @@ export class PostHogAnalytic extends AbstractAnalytic {
|
||||
}
|
||||
|
||||
public isFeatureFlagActivated(flagName: string): boolean {
|
||||
if (
|
||||
return !(
|
||||
posthog.featureFlags.getFlags().includes(flagName) &&
|
||||
posthog.isFeatureEnabled(flagName) === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,7 @@ export function isSafeUrl(url: string): boolean {
|
||||
}
|
||||
|
||||
// Check for directory traversal attempts
|
||||
if (url.includes('..') || url.includes('../') || url.includes('..\\')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return !(url.includes('..') || url.includes('../') || url.includes('..\\'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const globalRules = {
|
||||
'error',
|
||||
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
|
||||
],
|
||||
'no-var': 'error',
|
||||
'react/jsx-curly-brace-presence': [
|
||||
'error',
|
||||
{ props: 'never', children: 'never', propElementValues: 'always' },
|
||||
|
||||
@@ -13,5 +13,5 @@ module.exports = {
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
},
|
||||
ignorePatterns: ['node_modules', '.eslintrc.js'],
|
||||
ignorePatterns: ['node_modules'],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
var config = {
|
||||
const config = {
|
||||
rootDir: './__tests__',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
|
||||
@@ -17,8 +17,6 @@ import { logger } from '../utils';
|
||||
/**
|
||||
* init the collaboration server.
|
||||
*
|
||||
* @param port - The port on which the server listens.
|
||||
* @param serverSecret - The secret key for API authentication.
|
||||
* @returns An object containing the Express app, Hocuspocus server, and HTTP server instance.
|
||||
*/
|
||||
export const initServer = () => {
|
||||
|
||||
@@ -1238,11 +1238,16 @@
|
||||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emoji-mart/data@^1.2.1":
|
||||
"@emoji-mart/data@1.2.1", "@emoji-mart/data@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
|
||||
integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
|
||||
|
||||
"@emoji-mart/react@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
|
||||
integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
|
||||
|
||||
"@emotion/babel-plugin@^11.13.5":
|
||||
version "11.13.5"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0"
|
||||
@@ -7462,7 +7467,7 @@ emittery@^0.13.1:
|
||||
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
|
||||
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
|
||||
|
||||
emoji-mart@^5.6.0:
|
||||
emoji-mart@5.6.0, emoji-mart@^5.6.0:
|
||||
version "5.6.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023"
|
||||
integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==
|
||||
|
||||
Reference in New Issue
Block a user