diff --git a/customWordList.txt b/customWordList.txt index eba0216d..5b28b2e5 100644 --- a/customWordList.txt +++ b/customWordList.txt @@ -6,3 +6,4 @@ Videocam issuetype Autolinker NOSONAR +supportedLngs diff --git a/frontend/.env.example b/frontend/.env.example index 258bdde6..4fe741cd 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -13,3 +13,8 @@ VITE_BYPASS_WAITING_ROOM=false # For testing with multiple devices, you can use ngrok to expose your vite server to the internet. # Please refer to https://ngrok.com/docs/ for more information. VITE_TUNNEL_DOMAIN='' + +# Default language for i18next +VITE_I18N_FALLBACK_LANGUAGE='en' +# List all supported language, separated by | (example: VITE_I18N_SUPPORTED_LANGUAGES='en|fr|de|it') +VITE_I18N_SUPPORTED_LANGUAGES='en' diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 29f7fc8f..70d5a2bb 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -18,6 +18,6 @@ module.exports = { 'jsx-a11y/media-has-caption': 'off', 'jsx-a11y/no-static-element-interactions': 'off', 'jsx-a11y/click-events-have-key-events': 'off', - 'react/require-default-props': 'off' + 'react/require-default-props': 'off', }, }; diff --git a/frontend/package.json b/frontend/package.json index 28a45951..8daa662c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,12 +36,15 @@ "autoprefixer": "^10.4.19", "axios": "^1.12.0", "events": "^3.3.0", + "i18next": "^25.3.2", + "i18next-browser-languagedetector": "^8.2.0", "lodash": "^4.17.21", "opentok-layout-js": "^5.4.0", "opentok-solutions-logging": "^1.1.5", "postcss": "^8.4.38", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^15.6.1", "react-router-dom": "^6.11.0", "resize-observer-polyfill": "^1.5.1", "tailwindcss": "^3.4.14", diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx index 0bed30ab..10c469d3 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.spec.tsx @@ -12,7 +12,6 @@ import useUserContext from '../../../hooks/useUserContext'; import { UserContextType } from '../../user'; import useSessionContext from '../../../hooks/useSessionContext'; import { SessionContextType } from '../../SessionProvider/session'; -import { PUBLISHING_BLOCKED_CAPTION } from '../../../utils/constants'; vi.mock('@vonage/client-sdk-video'); vi.mock('../../../hooks/useUserContext.tsx'); @@ -240,7 +239,8 @@ describe('usePublisher', () => { const publishingBlockedError = { header: 'Difficulties joining room', - caption: PUBLISHING_BLOCKED_CAPTION, + caption: + "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", }; expect(result.current.publishingError).toEqual(publishingBlockedError); expect(mockedSessionPublish).toHaveBeenCalledTimes(2); diff --git a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx index 6e000296..5301e9d7 100644 --- a/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx +++ b/frontend/src/Context/PublisherProvider/usePublisher/usePublisher.tsx @@ -7,13 +7,10 @@ import OT, { ExceptionEvent, PublisherProperties, } from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; import usePublisherQuality, { NetworkQuality } from '../usePublisherQuality/usePublisherQuality'; import usePublisherOptions from '../usePublisherOptions'; import useSessionContext from '../../../hooks/useSessionContext'; -import { PUBLISHING_BLOCKED_CAPTION } from '../../../utils/constants'; -import getAccessDeniedError, { - PublishingErrorType, -} from '../../../utils/getAccessDeniedError/getAccessDeniedError'; import applyBackgroundFilter from '../../../utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter'; type PublisherStreamCreatedEvent = Event<'streamCreated', Publisher> & { @@ -29,6 +26,11 @@ type DeviceAccessStatus = { camera: boolean | undefined; }; +type PublishingErrorType = { + header: string; + caption: string; +} | null; + export type AccessDeniedEvent = Event<'accessDenied', Publisher> & { message?: string; }; @@ -72,6 +74,7 @@ export type PublisherContextType = { * @returns {PublisherContextType} the publisher context */ const usePublisher = (): PublisherContextType => { + const { t } = useTranslation(); const [publisherVideoElement, setPublisherVideoElement] = useState< HTMLVideoElement | HTMLObjectElement >(); @@ -96,10 +99,13 @@ const usePublisher = (): PublisherContextType => { useEffect(() => { if (deviceAccess?.microphone === false || deviceAccess?.camera === false) { const device = deviceAccess.camera ? 'Microphone' : 'Camera'; - const accessDeniedError = getAccessDeniedError(device); + const accessDeniedError = { + header: t('publishingErrors.accessDenied.title', { device }), + caption: t('publishingErrors.accessDenied.message', { device: device.toLowerCase() }), + }; setPublishingError(accessDeniedError); } - }, [deviceAccess]); + }, [deviceAccess, t]); useEffect(() => { if (!publisherOptions) { @@ -232,8 +238,8 @@ const usePublisher = (): PublisherContextType => { if (publishAttempt === 3) { const publishingBlocked: PublishingErrorType = { - header: 'Difficulties joining room', - caption: PUBLISHING_BLOCKED_CAPTION, + header: t('publishingErrors.blocked.title'), + caption: t('publishingErrors.blocked.message'), }; setPublishingError(publishingBlocked); setIsPublishingToSession(false); diff --git a/frontend/src/api/archiving/index.ts b/frontend/src/api/archiving/index.ts index 133aa27f..b6e6ae08 100644 --- a/frontend/src/api/archiving/index.ts +++ b/frontend/src/api/archiving/index.ts @@ -8,15 +8,16 @@ export type ArchiveResponse = { /** * Returns a list of archives and the status of the archives for a given meeting room. + * @param {string} locale - current locale * @param {string} roomName - The roomName we check for archives * @returns {Promise} The archives from the meeting room (if any) and whether any archives are pending. */ -const getArchives = async (roomName: string): Promise => { +const getArchives = async (locale: string, roomName: string): Promise => { const response = await listArchives(roomName); const archivesFromServer = response?.data?.archives; if (archivesFromServer instanceof Array) { const archives = archivesFromServer.map((archiveFromServer) => - createArchiveFromServer(archiveFromServer) + createArchiveFromServer(locale, archiveFromServer) ); return { archives, diff --git a/frontend/src/api/archiving/model.ts b/frontend/src/api/archiving/model.ts index a3bbf4cf..63185ef7 100644 --- a/frontend/src/api/archiving/model.ts +++ b/frontend/src/api/archiving/model.ts @@ -27,8 +27,8 @@ export type Archive = { createdAtFormatted: string; }; -const getDateString = (timestamp: number) => { - return `${getFormattedDate(timestamp)} ${getFormattedTime(timestamp)}`; +const getDateString = (locale: string, timestamp: number) => { + return `${getFormattedDate(locale, timestamp)} ${getFormattedTime(locale, timestamp)}`; }; const getArchiveStatus = (status: ServerArchiveStatus): ArchiveStatus => { @@ -47,16 +47,17 @@ const getArchiveStatus = (status: ServerArchiveStatus): ArchiveStatus => { /** * Modifies an archive retrieved from the server to be easily consumable. + * @param {string} locale - current locale * @param {ServerArchive} serverArchive - The archive to be modified. * @returns {Archive} The modified archive. */ -export const createArchiveFromServer = (serverArchive: ServerArchive): Archive => { +export const createArchiveFromServer = (locale: string, serverArchive: ServerArchive): Archive => { return { id: serverArchive.id, url: serverArchive.url, status: getArchiveStatus(serverArchive.status), createdAt: serverArchive.createdAt, - createdAtFormatted: getDateString(serverArchive.createdAt), + createdAtFormatted: getDateString(locale, serverArchive.createdAt), }; }; diff --git a/frontend/src/api/archiving/tests/index.spec.ts b/frontend/src/api/archiving/tests/index.spec.ts index a4460588..a7e79009 100644 --- a/frontend/src/api/archiving/tests/index.spec.ts +++ b/frontend/src/api/archiving/tests/index.spec.ts @@ -10,7 +10,7 @@ const mockListArchives = listArchives as Mock<[], ReturnType { it('it returns an object with array of Archives and hasPending flag', async () => { mockListArchives.mockResolvedValue(mockResponse); - const archives = await getArchives('roomName'); + const archives = await getArchives('en', 'roomName'); expect(archives).toEqual({ archives: [ { @@ -52,7 +52,7 @@ describe('getArchives', () => { }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as unknown as AxiosResponse); - const archives = await getArchives('roomName'); + const archives = await getArchives('en', 'roomName'); expect(archives).toEqual({ archives: [], hasPending: false, @@ -61,6 +61,6 @@ describe('getArchives', () => { it('it throws with error when api call throws', async () => { mockListArchives.mockRejectedValue(new AxiosError('Network Error', 'ERR_NETWORK')); - expect(getArchives('roomName')).rejects.toThrowError(); + expect(getArchives('en', 'roomName')).rejects.toThrowError(); }); }); diff --git a/frontend/src/api/archiving/tests/model.spec.ts b/frontend/src/api/archiving/tests/model.spec.ts index 22658967..b59e0268 100644 --- a/frontend/src/api/archiving/tests/model.spec.ts +++ b/frontend/src/api/archiving/tests/model.spec.ts @@ -4,7 +4,7 @@ import { availableServerArchive, failedServerArchive, startedServerArchive } fro describe('createArchiveFromServer', () => { it('should convert fields to model fields', () => { - const archive = createArchiveFromServer(availableServerArchive); + const archive = createArchiveFromServer('en', availableServerArchive); expect(archive).toEqual({ createdAt: 1725268141000, createdAtFormatted: 'Mon, Sep 2 5:09 AM', @@ -15,27 +15,27 @@ describe('createArchiveFromServer', () => { }); it('should return status as failed for failed and expired archives', () => { - expect(createArchiveFromServer(failedServerArchive).status).toBe('failed'); - expect(createArchiveFromServer({ ...failedServerArchive, status: 'expired' }).status).toBe( - 'failed' - ); + expect(createArchiveFromServer('en', failedServerArchive).status).toBe('failed'); + expect( + createArchiveFromServer('en', { ...failedServerArchive, status: 'expired' }).status + ).toBe('failed'); }); it('should return available as failed for status available archives', () => { - expect(createArchiveFromServer(availableServerArchive).status).toBe('available'); + expect(createArchiveFromServer('en', availableServerArchive).status).toBe('available'); }); it('should return status as pending for started, stopped, uploaded, and paused archives', () => { - expect(createArchiveFromServer(startedServerArchive).status).toBe('pending'); - expect(createArchiveFromServer({ ...startedServerArchive, status: 'stopped' }).status).toBe( - 'pending' - ); - expect(createArchiveFromServer({ ...startedServerArchive, status: 'uploaded' }).status).toBe( - 'pending' - ); - expect(createArchiveFromServer({ ...startedServerArchive, status: 'paused' }).status).toBe( - 'pending' - ); + expect(createArchiveFromServer('en', startedServerArchive).status).toBe('pending'); + expect( + createArchiveFromServer('en', { ...startedServerArchive, status: 'stopped' }).status + ).toBe('pending'); + expect( + createArchiveFromServer('en', { ...startedServerArchive, status: 'uploaded' }).status + ).toBe('pending'); + expect( + createArchiveFromServer('en', { ...startedServerArchive, status: 'paused' }).status + ).toBe('pending'); }); }); diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx index d0cb6f85..7f33dace 100644 --- a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx @@ -1,6 +1,7 @@ import { ReactElement } from 'react'; import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'; import { Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import SelectableOption from '../SelectableOption'; export type AddBackgroundEffectProps = { @@ -16,16 +17,17 @@ export type AddBackgroundEffectProps = { * @returns {ReactElement} A button for uploading background effects. */ const AddBackgroundEffect = ({ isDisabled = false }: AddBackgroundEffectProps): ReactElement => { + const { t } = useTranslation(); return ( - Recommended: JPG/PNG img. at 1280x720 resolution. + {t('backgroundEffects.recommended.specs')}
- Note: Images are stored only locally in the browser. + {t('backgroundEffects.recommended.note')} ) } diff --git a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx index 46f4a4ca..68e78d89 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx @@ -1,5 +1,6 @@ import { useRef, useState, useEffect, ReactElement } from 'react'; import { CircularProgress, useMediaQuery } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import waitUntilPlaying from '../../../utils/waitUntilPlaying'; export type BackgroundVideoContainerProps = { @@ -21,6 +22,7 @@ const BackgroundVideoContainer = ({ publisherVideoElement, isParentVideoEnabled = false, }: BackgroundVideoContainerProps): ReactElement => { + const { t } = useTranslation(); const containerRef = useRef(null); const [isVideoLoading, setIsVideoLoading] = useState(true); const isSMViewport = useMediaQuery(`(max-width:500px)`); @@ -75,7 +77,7 @@ const BackgroundVideoContainer = ({
{!isParentVideoEnabled && (
- You have not enabled video + {t('backgroundEffects.video.disabled')}
)} {isParentVideoEnabled &&
} diff --git a/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.tsx b/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.tsx index fdcf9e35..c8d7e658 100644 --- a/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.tsx +++ b/frontend/src/components/DeviceAccessAlert/DeviceAccessAlert.tsx @@ -1,13 +1,9 @@ import { AlertTitle, Box, Dialog, Stack, Alert } from '@mui/material'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import { DEVICE_ACCESS_STATUS } from '../../utils/constants'; import { isWebKit } from '../../utils/util'; -const askDeviceMessage = - 'To join the video room, your browser will request access to your camera and microphone.'; -const deniedDeviceMessage = - "It seems your browser is blocked from accessing your camera and/or microphone. Reset the permission state through your browser's UI."; - export type DeviceAccessAlertProps = { accessStatus: string | null; }; @@ -20,8 +16,11 @@ export type DeviceAccessAlertProps = { * @returns {ReactElement | false} - The rendered DeviceAccessAlert component if not Safari */ const DeviceAccessAlert = ({ accessStatus }: DeviceAccessAlertProps): ReactElement | false => { + const { t } = useTranslation(); const messageToDisplay = - accessStatus === DEVICE_ACCESS_STATUS.PENDING ? askDeviceMessage : deniedDeviceMessage; + accessStatus === DEVICE_ACCESS_STATUS.PENDING + ? t('deviceAccessAlert.askDeviceMessage') + : t('deviceAccessAlert.deniedDeviceMessage'); const imgToDisplay = accessStatus === DEVICE_ACCESS_STATUS.PENDING ? '/images/access-dialog-pending.png' @@ -59,7 +58,7 @@ const DeviceAccessAlert = ({ accessStatus }: DeviceAccessAlertProps): ReactEleme diff --git a/frontend/src/components/GHRepoButton/GHRepoButton.tsx b/frontend/src/components/GHRepoButton/GHRepoButton.tsx index 6c50db47..489af7ed 100644 --- a/frontend/src/components/GHRepoButton/GHRepoButton.tsx +++ b/frontend/src/components/GHRepoButton/GHRepoButton.tsx @@ -1,6 +1,7 @@ import { IconButton, Link, Tooltip } from '@mui/material'; import { GitHub as GitHubIcon } from '@mui/icons-material'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; /** * GHRepoButton Component @@ -9,9 +10,11 @@ import { ReactElement } from 'react'; * @returns {ReactElement} - The GHRepoButton component. */ const GHRepoButton = (): ReactElement => { + const { t } = useTranslation(); + return ( - + diff --git a/frontend/src/components/GoodBye/ArchiveList/ArchiveList.tsx b/frontend/src/components/GoodBye/ArchiveList/ArchiveList.tsx index b172b3ed..52f9072e 100644 --- a/frontend/src/components/GoodBye/ArchiveList/ArchiveList.tsx +++ b/frontend/src/components/GoodBye/ArchiveList/ArchiveList.tsx @@ -10,12 +10,15 @@ import { Tooltip, } from '@mui/material'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import { Archive, ArchiveStatus } from '../../../api/archiving/model'; const ArchiveDownloadButton = ({ url, id }: { id: string; url: string | undefined }) => { + const { t } = useTranslation(); + return ( - + @@ -24,33 +27,40 @@ const ArchiveDownloadButton = ({ url, id }: { id: string; url: string | undefine ); }; -const ArchiveErrorIcon = () => ( - - - -); +const ArchiveErrorIcon = () => { + const { t } = useTranslation(); -const ArchivingLoadingIcon = () => ( - - - -); + return ( + + + + ); +}; + +const ArchivingLoadingIcon = () => { + const { t } = useTranslation(); + return ( + + + + ); +}; const ArchiveStatusIcon = ({ status, @@ -83,18 +93,18 @@ export type ArchiveListProps = { * @returns {ReactElement} - The ArchiveList component. */ const ArchiveList = ({ archives }: ArchiveListProps): ReactElement => { + const { t } = useTranslation(); + if (archives === 'error') { return ( <> -

- There was an error loading recordings for this meeting -

+

{t('archiveList.error.text')}

); } if (!archives.length) { - return

There are no recordings for this meeting

; + return

{t('archiveList.empty')}

; } return (
@@ -109,8 +119,10 @@ const ArchiveList = ({ archives }: ArchiveListProps): ReactElement => { } > ); diff --git a/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.tsx b/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.tsx index 339f9784..40d43730 100644 --- a/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.tsx +++ b/frontend/src/components/GoodBye/GoToLandingPageButton/GoToLandingPageButton.tsx @@ -1,5 +1,6 @@ import { Button } from '@mui/material'; import { MouseEvent, ReactElement, TouchEvent } from 'react'; +import { useTranslation } from 'react-i18next'; export type GoToLandingPageButtonProps = { handleLanding: (event: MouseEvent | TouchEvent) => void; @@ -14,6 +15,8 @@ export type GoToLandingPageButtonProps = { * @returns {ReactElement} - the button to go back to the landing page. */ const GoToLandingPageButton = ({ handleLanding }: GoToLandingPageButtonProps): ReactElement => { + const { t } = useTranslation(); + return ( ); }; diff --git a/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.tsx b/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.tsx index dd7b09a0..d656e40a 100644 --- a/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.tsx +++ b/frontend/src/components/GoodBye/ReenterRoomButton/ReenterRoomButton.tsx @@ -1,5 +1,6 @@ import { Button } from '@mui/material'; import { MouseEvent, ReactElement, TouchEvent } from 'react'; +import { useTranslation } from 'react-i18next'; export type ReenterRoomButtonProps = { handleReenter: (event: MouseEvent | TouchEvent) => void; @@ -19,6 +20,8 @@ const ReenterRoomButton = ({ handleReenter, roomName, }: ReenterRoomButtonProps): ReactElement | string => { + const { t } = useTranslation(); + return ( roomName && ( ) ); diff --git a/frontend/src/components/JoinButton/JoinButton.tsx b/frontend/src/components/JoinButton/JoinButton.tsx index ae072f7a..b0d525b1 100644 --- a/frontend/src/components/JoinButton/JoinButton.tsx +++ b/frontend/src/components/JoinButton/JoinButton.tsx @@ -1,5 +1,6 @@ import { Button } from '@mui/material'; import { MouseEvent, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; export type JoinButtonProps = { @@ -16,6 +17,7 @@ export type JoinButtonProps = { * @returns {ReactElement} - the join room button. */ const JoinButton = ({ roomName, isDisabled }: JoinButtonProps): ReactElement => { + const { t } = useTranslation(); const navigate = useNavigate(); const handleJoin = (event: MouseEvent) => { @@ -31,7 +33,7 @@ const JoinButton = ({ roomName, isDisabled }: JoinButtonProps): ReactElement => onClick={handleJoin} type="submit" > - Join + {t('button.join')} ); }; diff --git a/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.tsx b/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.tsx index db55893a..091ea2bb 100644 --- a/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.tsx +++ b/frontend/src/components/JoinContainerSeparator/JoinContainerSeparator.tsx @@ -1,4 +1,5 @@ import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import Separator from '../Separator'; /** @@ -8,10 +9,11 @@ import Separator from '../Separator'; * @returns {ReactElement} The JoinContainerSeparator component. */ const JoinContainerSeparator = (): ReactElement => { + const { t } = useTranslation(); return (
- or + {t('common.or')}
); diff --git a/frontend/src/components/LandingPageWelcome/LandingPageWelcome.tsx b/frontend/src/components/LandingPageWelcome/LandingPageWelcome.tsx index ba213335..6eb81134 100644 --- a/frontend/src/components/LandingPageWelcome/LandingPageWelcome.tsx +++ b/frontend/src/components/LandingPageWelcome/LandingPageWelcome.tsx @@ -1,4 +1,5 @@ import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; /** * LandingPageWelcome Component @@ -6,12 +7,12 @@ import { ReactElement } from 'react'; * @returns {ReactElement} - the landing page component */ const LandingPageWelcome = (): ReactElement => { + const { t } = useTranslation(); + return (
-

- Welcome to the Vonage Video React App -

-

Create a new room or join an existing one.

+

{t('landing.welcome.title')}

+

{t('landing.welcome.subtitle')}

); }; diff --git a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx index 09dd07cc..eb6cd9a0 100644 --- a/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx +++ b/frontend/src/components/MeetingRoom/ArchivingButton/ArchivingButton.tsx @@ -1,6 +1,7 @@ import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; import { Tooltip } from '@mui/material'; import { ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import useRoomName from '../../../hooks/useRoomName'; import ToolbarButton from '../ToolbarButton'; import PopupDialog, { DialogTexts } from '../PopupDialog'; @@ -27,28 +28,28 @@ const ArchivingButton = ({ isOverflowButton = false, handleClick, }: ArchivingButtonProps): ReactElement => { + const { t } = useTranslation(); const roomName = useRoomName(); const { archiveId } = useSessionContext(); const isRecording = !!archiveId; const [isModalOpen, setIsModalOpen] = useState(false); - const title = isRecording ? 'Stop recording' : 'Start recording'; + const title = isRecording ? t('recording.stop.title') : t('recording.start.title'); const handleButtonClick = () => { setIsModalOpen((prev) => !prev); }; const startRecordingText: DialogTexts = { - title: 'Start Recording?', - contents: - 'Make sure everyone is ready! You can download the recording from the "Goodbye" page after you leave the room.', - primaryActionText: 'Start Recording', - secondaryActionText: 'Cancel', + title: t('recording.start.dialog.title'), + contents: t('recording.start.dialog.content'), + primaryActionText: t('recording.start.title'), + secondaryActionText: t('button.cancel'), }; const stopRecordingText: DialogTexts = { - title: 'Stop Recording?', - contents: 'You can download the recording from the "Goodbye" page after you leave the room.', - primaryActionText: 'Stop Recording', - secondaryActionText: 'Cancel', + title: t('recording.stop.dialog.title'), + contents: t('recording.stop.dialog.content'), + primaryActionText: t('recording.stop.title'), + secondaryActionText: t('button.cancel'), }; const actionText = isRecording ? stopRecordingText : startRecordingText; @@ -83,7 +84,7 @@ const ArchivingButton = ({ return ( <> - + { + const { t } = useTranslation(); const { forceMute } = useSessionContext(); const [isModalOpen, setIsModalOpen] = useState(false); const muteParticipantText: DialogTexts = { - contents: `Mute ${participantName} for everyone in the call? Only ${participantName} can unmute themselves.`, - primaryActionText: 'Mute', - secondaryActionText: 'Cancel', + contents: t('participants.mute.dialog.content', { participantName }), + primaryActionText: t('button.mute'), + secondaryActionText: t('button.cancel'), }; const handleClick = () => { setIsModalOpen(true); @@ -71,7 +73,7 @@ const AudioIndicator = ({ return (
- + - + - Choose Background Effect + {t('backgroundEffects.choice')} - Cancel + {t('button.cancel')} diff --git a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.tsx b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.tsx index f30e4625..cdff2d3e 100644 --- a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.tsx +++ b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsBox/UserCaption/UserCaption.tsx @@ -1,6 +1,7 @@ import { Subscriber } from '@vonage/client-sdk-video'; import { ReactElement, useState, useRef, useEffect } from 'react'; import { Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import useReceivingCaptions from '../../../../../hooks/useReceivingCaptions'; import { CAPTION_DISPLAY_DURATION_MS } from '../../../../../utils/constants'; @@ -23,6 +24,7 @@ const UserCaption = ({ isSmallViewPort, caption, }: UserCaptionProps): ReactElement | null => { + const { t } = useTranslation(); const { caption: captionText, isReceivingCaptions } = useReceivingCaptions({ subscriber, }); @@ -72,7 +74,7 @@ const UserCaption = ({ fontSize: isSmallViewPort ? '1rem' : '1.25rem', }} > - {subscriber?.stream?.name ?? 'You'}: {displayCaption} + {subscriber?.stream?.name ?? t('user.you')}: {displayCaption}
); diff --git a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx index 16f33624..0f5b1ebb 100644 --- a/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx +++ b/frontend/src/components/MeetingRoom/CaptionsButton/CaptionsButton.tsx @@ -2,6 +2,7 @@ import { ClosedCaption, ClosedCaptionDisabled } from '@mui/icons-material'; import { Tooltip } from '@mui/material'; import { Dispatch, ReactElement, useState, SetStateAction } from 'react'; import { AxiosError } from 'axios'; +import { useTranslation } from 'react-i18next'; import useRoomName from '../../../hooks/useRoomName'; import ToolbarButton from '../ToolbarButton'; import { disableCaptions, enableCaptions } from '../../../api/captions'; @@ -33,11 +34,12 @@ const CaptionsButton = ({ handleClick, captionsState, }: CaptionsButtonProps): ReactElement => { + const { t } = useTranslation(); const roomName = useRoomName(); const [captionsId, setCaptionsId] = useState(''); const { isUserCaptionsEnabled, setIsUserCaptionsEnabled, setCaptionsErrorResponse } = captionsState; - const title = isUserCaptionsEnabled ? 'Disable captions' : 'Enable captions'; + const title = isUserCaptionsEnabled ? t('captions.disable') : t('captions.enable'); const handleClose = () => { if (isOverflowButton && handleClick) { @@ -48,7 +50,7 @@ const CaptionsButton = ({ const sessionCaptionsEnabled = !!roomName && !!captionsId; const handleCaptionsErrorResponse = (message: string | null) => { - setCaptionsErrorResponse(message || 'Unknown error occurred'); + setCaptionsErrorResponse(message || t('errors.unknown')); setCaptionsId(''); setIsUserCaptionsEnabled(false); }; @@ -91,7 +93,7 @@ const CaptionsButton = ({ }; return ( - + { + const { t } = useTranslation(); const isSmallViewport = useIsSmallViewport(); return ( - Captions error: {captionsErrorResponse} + {t('captions.errors', { captionsErrorResponse })} ); diff --git a/frontend/src/components/MeetingRoom/Chat/Chat.tsx b/frontend/src/components/MeetingRoom/Chat/Chat.tsx index c1007967..bde5dd1e 100644 --- a/frontend/src/components/MeetingRoom/Chat/Chat.tsx +++ b/frontend/src/components/MeetingRoom/Chat/Chat.tsx @@ -1,5 +1,6 @@ import { ReactElement } from 'react'; import { List } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import getInitials from '../../../utils/getInitials'; import getParticipantColor from '../../../utils/getParticipantColor'; import ChatMessage from '../ChatMessage'; @@ -21,13 +22,14 @@ export type ChatProps = { * @returns {ReactElement | false} - Chat component */ const Chat = ({ handleClose, isOpen }: ChatProps): ReactElement | false => { + const { t } = useTranslation(); const { messages } = useSessionContext(); const heightClass = '@apply h-[calc(100dvh_-_240px)]'; return ( isOpen && ( <> - +
{messages.map((msg) => { diff --git a/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx b/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx index fcb34722..910b4bec 100644 --- a/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx +++ b/frontend/src/components/MeetingRoom/ChatButton/ChatButton.tsx @@ -2,6 +2,7 @@ import ChatIcon from '@mui/icons-material/Chat'; import Tooltip from '@mui/material/Tooltip'; import { blue } from '@mui/material/colors'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import ToolbarButton from '../ToolbarButton'; import UnreadMessagesBadge from '../UnreadMessagesBadge'; @@ -27,8 +28,9 @@ const ChatButton = ({ isOpen, isOverflowButton = false, }: ChatButtonProps): ReactElement => { + const { t } = useTranslation(); return ( - + { + const { t } = useTranslation(); const [text, setText] = useState(''); const [isComposing, setIsComposing] = useState(false); const { sendChatMessage } = useSessionContext(); @@ -50,7 +52,7 @@ const ChatInput = (): ReactElement => { name="Solid" multiline variant="standard" - placeholder="Send a message" + placeholder={t('chat.input.placeholder')} onKeyDown={handleKeyDown} onChange={(e) => setText(e.target.value)} onCompositionStart={handleCompositionStart} diff --git a/frontend/src/components/MeetingRoom/ChatMessage/ChatMessage.tsx b/frontend/src/components/MeetingRoom/ChatMessage/ChatMessage.tsx index 4a6f9428..bc72c07d 100644 --- a/frontend/src/components/MeetingRoom/ChatMessage/ChatMessage.tsx +++ b/frontend/src/components/MeetingRoom/ChatMessage/ChatMessage.tsx @@ -1,5 +1,6 @@ import { Avatar, ListItem, ListItemText, Typography } from '@mui/material'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import { getFormattedTime } from '../../../utils/dateTime'; import FormattedMessageBody from '../FormattedMessageBody'; @@ -30,6 +31,7 @@ const ChatMessage = ({ name, timestamp, }: ChatMessageProps): ReactElement => { + const { i18n } = useTranslation(); return ( - {getFormattedTime(timestamp)} + {getFormattedTime(i18n.language, timestamp)} } diff --git a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx index 57ea8a79..bbef16a6 100644 --- a/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx +++ b/frontend/src/components/MeetingRoom/DeviceControlButton/DeviceControlButton.tsx @@ -6,6 +6,7 @@ import Tooltip from '@mui/material/Tooltip'; import ButtonGroup from '@mui/material/ButtonGroup'; import { MicOff, ArrowDropUp, ArrowDropDown } from '@mui/icons-material'; import { useState, useRef, useCallback, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import MutedAlert from '../../MutedAlert'; import usePublisherContext from '../../../hooks/usePublisherContext'; import DeviceSettingsMenu from '../DeviceSettingsMenu'; @@ -30,13 +31,14 @@ const DeviceControlButton = ({ deviceType, toggleBackgroundEffects, }: DeviceControlButtonProps): ReactElement => { + const { t } = useTranslation(); const { isVideoEnabled, toggleAudio, toggleVideo, isAudioEnabled } = usePublisherContext(); const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext(); const isAudio = deviceType === 'audio'; const [open, setOpen] = useState(false); const anchorRef = useRef(null); - const audioTitle = isAudioEnabled ? 'Disable microphone' : 'Enable microphone'; - const videoTitle = isVideoEnabled ? 'Disable video' : 'Enable video'; + const audioTitle = isAudioEnabled ? t('devices.audio.disable') : t('devices.audio.enable'); + const videoTitle = isVideoEnabled ? t('devices.video.disable') : t('devices.video.enable'); const handleToggle = () => { setOpen((prevOpen) => !prevOpen); @@ -81,13 +83,13 @@ const DeviceControlButton = ({ sx={{ borderRadius: '30px' }} variant="contained" ref={anchorRef} - aria-label="split button" + aria-label={t('devices.buttons.ariaLabel')} > )} - + { + const { t } = useTranslation(); const anchorRef = useRef(null); const handleToggle = () => { setIsEmojiGridOpen((prevOpen) => !prevOpen); @@ -35,7 +37,7 @@ const EmojiGridButton = ({ return ( <> - + { + const { t } = useTranslation(); const navigate = useNavigate(); const roomName = useRoomName(); @@ -27,7 +29,7 @@ const ExitButton = ({ handleLeave }: ExitButtonProps): ReactElement => { }; return ( - + { + const { t } = useTranslation(); const { publisher } = usePublisherContext(); const { allMediaDevices: { audioInputDevices }, @@ -53,7 +55,7 @@ const InputDevices = ({ handleToggle, customLightBlueColor }: InputDevicesProps) }} > - Microphone + {t('devices.audio.microphone.full')} {options.map((option: string) => { diff --git a/frontend/src/components/MeetingRoom/LayoutButton/LayoutButton.tsx b/frontend/src/components/MeetingRoom/LayoutButton/LayoutButton.tsx index 470c20fb..51704000 100644 --- a/frontend/src/components/MeetingRoom/LayoutButton/LayoutButton.tsx +++ b/frontend/src/components/MeetingRoom/LayoutButton/LayoutButton.tsx @@ -2,6 +2,7 @@ import ViewSidebarIcon from '@mui/icons-material/ViewSidebar'; import Tooltip from '@mui/material/Tooltip'; import WindowIcon from '@mui/icons-material/Window'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import useSessionContext from '../../../hooks/useSessionContext'; import ToolbarButton from '../ToolbarButton'; @@ -26,6 +27,7 @@ const LayoutButton = ({ isPinningPresent, isOverflowButton = false, }: LayoutButtonProps): ReactElement => { + const { t } = useTranslation(); const { layoutMode, setLayoutMode } = useSessionContext(); const isGrid = layoutMode === 'grid'; const isDisabled = isScreenSharePresent || isPinningPresent; @@ -39,16 +41,16 @@ const LayoutButton = ({ const getTooltipTitle = () => { if (isScreenSharePresent) { - return 'Cannot switch layout while screen share is active'; + return t('layout.tooltip.isScreenSharePresent'); } if (isPinningPresent) { - return 'Cannot switch layout while a participant is pinned'; + return t('layout.tooltip.isPinningPresent'); } - return isGrid ? 'Switch to Active Speaker layout' : 'Switch to Grid layout'; + return isGrid ? t('layout.tooltip.switchToActiveSpeaker') : t('layout.tooltip.switchToGrid'); }; return ( - + { + const { t } = useTranslation(); const { forceMute } = useSessionContext(); const muteParticipantText: MutingDialogTexts = { - contents: `Mute ${stream?.name} for everyone in the call? Only ${stream?.name} can unmute themselves.`, - primaryActionText: 'Mute', - secondaryActionText: 'Cancel', + contents: t('participants.mute.dialog.content', { participantName: stream?.name }), + primaryActionText: t('button.mute'), + secondaryActionText: t('button.cancel'), }; const handleClose = () => { diff --git a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx index 26c70297..dd3a72ee 100644 --- a/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx +++ b/frontend/src/components/MeetingRoom/OutputDevices/OutputDevices.tsx @@ -3,13 +3,12 @@ import CheckIcon from '@mui/icons-material/Check'; import VolumeUpIcon from '@mui/icons-material/VolumeUp'; import { MouseEvent, ReactElement } from 'react'; import type { AudioOutputDevice } from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; import useDevices from '../../../hooks/useDevices'; import DropdownSeparator from '../DropdownSeparator'; import useAudioOutputContext from '../../../hooks/useAudioOutputContext'; import { isGetActiveAudioOutputDeviceSupported } from '../../../utils/util'; -const defaultOutputDevices = [{ deviceId: 'default', label: 'System Default' }]; - export type OutputDevicesProps = { handleToggle: () => void; customLightBlueColor: string; @@ -28,10 +27,12 @@ const OutputDevices = ({ handleToggle, customLightBlueColor, }: OutputDevicesProps): ReactElement => { + const { t } = useTranslation(); const { currentAudioOutputDevice, setAudioOutputDevice } = useAudioOutputContext(); const { allMediaDevices: { audioOutputDevices }, } = useDevices(); + const defaultOutputDevices = [{ deviceId: 'default', label: t('devices.audio.defaultLabel') }]; const isAudioOutputSupported = isGetActiveAudioOutputDeviceSupported(); @@ -64,7 +65,9 @@ const OutputDevices = ({ }} > - Speakers + + {t('devices.audio.speakers.full')} + {availableDevices?.map((device: AudioOutputDevice) => { diff --git a/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.tsx b/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.tsx index 5619c743..0e0e2fed 100644 --- a/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.tsx +++ b/frontend/src/components/MeetingRoom/ParticipantList/ParticipantList.tsx @@ -2,6 +2,7 @@ import { Fade, IconButton, List, Tooltip } from '@mui/material'; import { ContentCopy } from '@mui/icons-material'; import CheckIcon from '@mui/icons-material/Check'; import { ReactElement, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import useSessionContext from '../../../hooks/useSessionContext'; import useUserContext from '../../../hooks/useUserContext'; import useAudioLevels from '../../../hooks/useAudioLevels'; @@ -41,6 +42,7 @@ export type ParticipantListProps = { * @returns {ReactElement} The participant list component. */ const ParticipantList = ({ handleClose, isOpen }: ParticipantListProps): ReactElement | false => { + const { t } = useTranslation(); const { subscriberWrappers } = useSessionContext(); const publisherAudio = useAudioLevels(); const [isCopied, setIsCopied] = useState(false); @@ -65,10 +67,12 @@ const ParticipantList = ({ handleClose, isOpen }: ParticipantListProps): ReactEl return ( isOpen && ( <> - +
- Meeting URL:{' '} + + {t('chat.meetingUrl')} + {' '}
{window.location.href} @@ -81,7 +85,7 @@ const ParticipantList = ({ handleClose, isOpen }: ParticipantListProps): ReactEl disabled={isCopied} > @@ -95,7 +99,7 @@ const ParticipantList = ({ handleClose, isOpen }: ParticipantListProps): ReactEl dataTestId="participant-list-item-you" hasAudio={isAudioEnabled} audioLevel={isAudioEnabled ? publisherAudio : undefined} - name={`${name} (You)`} + name={`${name} (${t('user.you')})`} initials={getInitials(name)} avatarColor={getParticipantColor(name)} /> diff --git a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx index a4176de5..7afd4671 100644 --- a/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx +++ b/frontend/src/components/MeetingRoom/ParticipantListButton/ParticipantListButton.tsx @@ -3,6 +3,7 @@ import Tooltip from '@mui/material/Tooltip'; import { blue } from '@mui/material/colors'; import { Badge } from '@mui/material'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import ToolbarButton from '../ToolbarButton'; export type ParticipantListButtonProps = { @@ -29,10 +30,11 @@ const ParticipantListButton = ({ participantCount, isOverflowButton = false, }: ParticipantListButtonProps): ReactElement => { + const { t } = useTranslation(); return ( { + const { t } = useTranslation(); const { isPinned, id } = subscriberWrapper; const { isMaxPinned, pinSubscriber } = useSessionContext(); const isDisabled = !isPinned && isMaxPinned; const getText = () => { if (isPinned) { - return `Unpin ${participantName}`; + return t('participants.unpin', { participantName }); } if (isMaxPinned) { - return `You can't pin any more tiles`; + return t('participants.maxPin'); } - return `Pin ${participantName}`; + return t('participants.pin', { participantName }); }; const handlePinClick = () => { if (!isDisabled) { diff --git a/frontend/src/components/MeetingRoom/PinButton/PinButton.tsx b/frontend/src/components/MeetingRoom/PinButton/PinButton.tsx index aea64d4b..c2b899ef 100644 --- a/frontend/src/components/MeetingRoom/PinButton/PinButton.tsx +++ b/frontend/src/components/MeetingRoom/PinButton/PinButton.tsx @@ -1,6 +1,7 @@ import PushPinIcon from '@mui/icons-material/PushPin'; import { IconButton, Tooltip } from '@mui/material'; import { MouseEvent, ReactElement, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import PushPinOffIcon from '../../Icons/PushPinOffIcon'; import isMouseEventInsideBox from '../../../utils/isMouseEventInsideBox'; @@ -31,6 +32,7 @@ const PinButton = ({ participantName, handleClick, }: PinButtonProps): ReactElement | false => { + const { t } = useTranslation(); const isDisabled = isMaxPinned && !isPinned; const anchorRef = useRef(null); const [isHoveringButton, setIsHoveringButton] = useState(false); @@ -42,12 +44,12 @@ const PinButton = ({ const getTooltipText = () => { if (isDisabled) { - return `You can't pin any more tiles`; + return t('participants.maxPin'); } if (isPinned) { - return `Unpin ${participantName}'s video`; + return t('participants.unpin.video', { participantName }); } - return `Pin ${participantName}'s video`; + return t('participants.pin.video', { participantName }); }; const onClick = (clickEvent: MouseEvent) => { diff --git a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx index 2f248375..ce3eb782 100644 --- a/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx +++ b/frontend/src/components/MeetingRoom/ReduceNoiseTestSpeakers/ReduceNoiseTestSpeakers.tsx @@ -6,6 +6,7 @@ import { hasMediaProcessorSupport } from '@vonage/client-sdk-video'; import HeadsetIcon from '@mui/icons-material/Headset'; import ToggleOffIcon from '@mui/icons-material/ToggleOff'; import ToggleOnIcon from '@mui/icons-material/ToggleOn'; +import { useTranslation } from 'react-i18next'; import usePublisherContext from '../../../hooks/usePublisherContext'; import DropdownSeparator from '../DropdownSeparator'; import SoundTest from '../../SoundTest'; @@ -27,6 +28,7 @@ export type ReduceNoiseTestSpeakersProps = { const ReduceNoiseTestSpeakers = ({ customLightBlueColor, }: ReduceNoiseTestSpeakersProps): ReactElement | false => { + const { t } = useTranslation(); const { publisher, isPublishing } = usePublisherContext(); const [isToggled, setIsToggled] = useState(false); @@ -70,7 +72,7 @@ const ReduceNoiseTestSpeakers = ({ > - Advanced Noise Suppression + {t('devices.audio.noiseSuppression')} diff --git a/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.tsx b/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.tsx index 322b5975..e1fcc4c1 100644 --- a/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.tsx +++ b/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FeedbackForm.tsx @@ -1,5 +1,6 @@ import { TextField, Button, Typography, Box, CircularProgress } from '@mui/material'; import { FormEvent, ChangeEvent, ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import FilePicker from './FilePicker'; import { REPORT_TITLE_LIMIT, @@ -59,6 +60,7 @@ const FeedbackForm = ({ loading, onFileSelect, }: FeedbackFormType): ReactElement => { + const { t } = useTranslation(); const isSmallViewport = useIsSmallViewport(); // 224px = 64px panel header + 96px toolbar if normal viewport + (40px submit button + 24px submit button margin) // 208px = 64px panel header + 80px toolbar if small viewport + (40px submit button + 24px submit button margin) @@ -96,7 +98,7 @@ const FeedbackForm = ({ color="textPrimary" sx={getStyleTypography()} > - When you noticed this issue, what were you trying to do? + {t('feedbackForm.field.title.label')} - Tell us your name + {t('feedbackForm.field.name.label')} - Describe your issue + {t('feedbackForm.field.issue.label')} - Please do not include any sensitive information. + {t('feedbackForm.disclaiamer.label')} - A screenshot will help us better understand the issue. (optional) + {t('feedbackForm.disclaiamer.screenshot')} @@ -226,7 +228,7 @@ const FeedbackForm = ({ width, }} > - Send + {t('button.send')} ); diff --git a/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.tsx b/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.tsx index c7e56079..d2891858 100644 --- a/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.tsx +++ b/frontend/src/components/MeetingRoom/ReportIssue/FeedbackForm/FilePicker/FilePicker.tsx @@ -1,6 +1,7 @@ import { ChangeEvent, useRef, useState, ReactElement } from 'react'; import { Button, IconButton, Tooltip, Typography } from '@mui/material'; import { Delete } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; import captureScreenshot from '../../../../../utils/captureScreenshot'; import { isMobile } from '../../../../../utils/util'; @@ -20,6 +21,7 @@ const FilePicker = ({ }: { onFileSelect: (fileData: string) => void; }): ReactElement => { + const { t } = useTranslation(); const [imageSrc, setImageSrc] = useState(''); const imageRef = useRef(null); const [maximumSizeError, setMaximumSizeError] = useState(false); @@ -78,7 +80,7 @@ const FilePicker = ({ textAlign: 'left', }} > - Attached screenshot + {t('filePicker.attachedScreenshot')} )}
@@ -90,7 +92,7 @@ const FilePicker = ({ textAlign: 'left', }} > - The maximum upload size is 20MB. Please upload another file. + {t('filePicker.sizeLimit')} )} {!imageSrc ? ( @@ -108,7 +110,7 @@ const FilePicker = ({ component="label" onClick={processScreenshot} > - Capture screenshot + {t('filePicker.capture')} )}
diff --git a/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.tsx b/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.tsx index d720ff6f..4072b334 100644 --- a/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.tsx +++ b/frontend/src/components/MeetingRoom/ReportIssue/ReportIssue.tsx @@ -1,5 +1,6 @@ import { ChangeEvent, ReactElement, useState, FormEvent } from 'react'; import { AxiosError } from 'axios'; +import { useTranslation } from 'react-i18next'; import RightPanelTitle from '../RightPanel/RightPanelTitle'; import useCollectBrowserInformation from '../../../hooks/useCollectBrowserInformation'; import FormSubmitted from './FormSubmitted'; @@ -41,6 +42,7 @@ type ResponseType = { * @returns {ReactElement} The report issue component. */ const ReportIssue = ({ handleClose, isOpen }: ReportIssueProps): ReactElement | false => { + const { t } = useTranslation(); const [formData, setFormData] = useState({ title: '', name: '', @@ -150,7 +152,7 @@ const ReportIssue = ({ handleClose, isOpen }: ReportIssueProps): ReactElement | return ( isOpen && ( <> - + {isFormVisible ? ( { + const { t } = useTranslation(); const anchorRef = useRef(null); return ( { + const { t } = useTranslation(); const [isToolbarOverflowMenuOpen, setIsToolbarOverflowMenuOpen] = useState(false); const [openEmojiGridMobile, setOpenEmojiGridMobile] = useState(true); @@ -55,10 +57,7 @@ const ToolbarOverflowButton = ({ return ( <> - + { + const { t } = useTranslation(); const { isPublishing, publisher } = usePublisherContext(); const { allMediaDevices } = useDevices(); const [devicesAvailable, setDevicesAvailable] = useState([]); @@ -67,7 +69,7 @@ const VideoDevices = ({ handleToggle, customLightBlueColor }: VideoDevicesProps) }} > - Camera + {t('devices.video.camera.full')} {options.map((option) => { diff --git a/frontend/src/components/MutedAlert/MutedAlert.tsx b/frontend/src/components/MutedAlert/MutedAlert.tsx index e9cb5ec1..eba016fd 100644 --- a/frontend/src/components/MutedAlert/MutedAlert.tsx +++ b/frontend/src/components/MutedAlert/MutedAlert.tsx @@ -1,7 +1,7 @@ import Fade from '@mui/material/Fade'; import { useState, useEffect, ReactElement } from 'react'; import { Alert } from '@mui/material'; -import { MUTED_ALERT_MESSAGE, FORCE_MUTED_ALERT_MESSAGE } from '../../utils/constants'; +import { useTranslation } from 'react-i18next'; import useSpeakingDetector from '../../hooks/useSpeakingDetector'; import usePublisherContext from '../../hooks/usePublisherContext'; import useIsSmallViewport from '../../hooks/useIsSmallViewport'; @@ -13,6 +13,7 @@ import useIsSmallViewport from '../../hooks/useIsSmallViewport'; * @returns {ReactElement} - The MutedAlert component. */ const MutedAlert = (): ReactElement => { + const { t } = useTranslation(); const { publisher, isAudioEnabled, isForceMuted } = usePublisherContext(); const [open, setOpen] = useState(false); const isSpeakingWhileMuted = useSpeakingDetector({ @@ -20,7 +21,9 @@ const MutedAlert = (): ReactElement => { selectedMicrophoneId: publisher?.getAudioSource()?.id, }); const isSmallViewport = useIsSmallViewport(); - const messageToDisplay = isForceMuted ? FORCE_MUTED_ALERT_MESSAGE : MUTED_ALERT_MESSAGE; + const messageToDisplay = isForceMuted + ? t('mutedAlert.message.forceMuted') + : t('mutedAlert.message.muted'); useEffect(() => { setOpen(isForceMuted || isSpeakingWhileMuted); diff --git a/frontend/src/components/NewRoomButton/NewRoomButton.tsx b/frontend/src/components/NewRoomButton/NewRoomButton.tsx index b6a436ef..0f2fe11b 100644 --- a/frontend/src/components/NewRoomButton/NewRoomButton.tsx +++ b/frontend/src/components/NewRoomButton/NewRoomButton.tsx @@ -1,6 +1,7 @@ import { Button } from '@mui/material'; import { VideoCall } from '@mui/icons-material'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; export type NewRoomButtonProps = { handleNewRoom: () => void; @@ -15,6 +16,7 @@ export type NewRoomButtonProps = { * @returns {ReactElement} The new room button component. */ const NewRoomButton = ({ handleNewRoom }: NewRoomButtonProps): ReactElement => { + const { t } = useTranslation(); return ( ); }; diff --git a/frontend/src/components/RoomNameInput/RoomNameInput.tsx b/frontend/src/components/RoomNameInput/RoomNameInput.tsx index b3e37f42..8aa4dac9 100644 --- a/frontend/src/components/RoomNameInput/RoomNameInput.tsx +++ b/frontend/src/components/RoomNameInput/RoomNameInput.tsx @@ -1,6 +1,7 @@ import { SetStateAction, Dispatch, ReactElement, ChangeEvent } from 'react'; import { InputAdornment, TextField } from '@mui/material'; import { Keyboard } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; import isValidRoomName from '../../utils/isValidRoomName'; export type RoomNameInputProps = { @@ -27,6 +28,7 @@ const RoomNameInput = ({ hasError, setHasError, }: RoomNameInputProps): ReactElement => { + const { t } = useTranslation(); const handleChange = (textChangeEvent: ChangeEvent) => { const newValue = textChangeEvent.target.value.toLowerCase(); @@ -48,11 +50,11 @@ const RoomNameInput = ({ { - const title = isSharingScreen ? 'Stop screen share' : 'Start screen share'; + const { t } = useTranslation(); + const title = isSharingScreen ? t('screenSharing.title.stop') : t('screenSharing.title.start'); const [isModalOpen, setIsModalOpen] = useState(false); const handleButtonClick = () => @@ -41,11 +43,10 @@ const ScreenSharingButton = ({ }; const actionText: DialogTexts = { - title: 'Do you want to share your screen?', - contents: - 'Looks like there is someone else sharing their screen. If you continue, their screen is no longer going to be shared.', - primaryActionText: 'Start sharing your screen', - secondaryActionText: 'Cancel', + title: t('screenSharing.dialog.title'), + contents: t('screenSharing.dialog.content'), + primaryActionText: t('screenSharing.dialog.action'), + secondaryActionText: t('button.cancel'), }; const handleActionClick = () => { @@ -58,7 +59,7 @@ const ScreenSharingButton = ({ // See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#browser_compatibility !isMobile() && ( <> - + { + const { t } = useTranslation(); const [audioIsPlaying, setAudioIsPlaying] = useState(false); const audioElement = useMemo(() => new Audio('/sound.mp3'), []); const { currentAudioOutputDevice } = useAudioOutputContext(); @@ -45,7 +47,9 @@ const SoundTest = ({ children }: SoundTestProps): ReactElement => { stopAudio()}> {children} - {!audioIsPlaying ? 'Test speakers' : 'Stop testing'} + + {!audioIsPlaying ? t('soundTest.start') : t('soundTest.stop')} + ); diff --git a/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/SupportedBrowserListItem.tsx b/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/SupportedBrowserListItem.tsx index 26d08d8e..51da5ab2 100644 --- a/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/SupportedBrowserListItem.tsx +++ b/frontend/src/components/UnsupportedBrowser/SupportedBrowserListItem/SupportedBrowserListItem.tsx @@ -1,6 +1,7 @@ import { IconButton, Link, ListItem, ListItemIcon, ListItemText, Tooltip } from '@mui/material'; import { ReactElement } from 'react'; import OpenInNewOutlinedIcon from '@mui/icons-material/OpenInNewOutlined'; +import { useTranslation } from 'react-i18next'; export type SupportedBrowserListItemProps = { url: string; @@ -16,11 +17,12 @@ const SupportedBrowserListItem = ({ url, browser, }: SupportedBrowserListItemProps): ReactElement => { + const { t } = useTranslation(); return ( - + diff --git a/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/SupportedBrowsers.tsx b/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/SupportedBrowsers.tsx index 03a8c7f7..3ca4ca21 100644 --- a/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/SupportedBrowsers.tsx +++ b/frontend/src/components/UnsupportedBrowser/SupportedBrowsers/SupportedBrowsers.tsx @@ -1,5 +1,6 @@ import { ReactElement } from 'react'; import { List } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import { SUPPORTED_BROWSERS } from '../../../utils/constants'; import SupportedBrowserListItem from '../SupportedBrowserListItem'; @@ -10,9 +11,10 @@ import SupportedBrowserListItem from '../SupportedBrowserListItem'; * @returns {ReactElement} The SupportedBrowsers component. */ const SupportedBrowsers = (): ReactElement => { + const { t } = useTranslation(); return (
-

Supported browsers:

+

{t('unsupportedBrowser.supported.title')}

diff --git a/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/UnsupportedBrowserMessage.tsx b/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/UnsupportedBrowserMessage.tsx index a51ec89f..921a31ce 100644 --- a/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/UnsupportedBrowserMessage.tsx +++ b/frontend/src/components/UnsupportedBrowser/UnsupportedBrowserMessage/UnsupportedBrowserMessage.tsx @@ -1,4 +1,5 @@ import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; /** * UnsupportedBrowserMessage Component @@ -7,13 +8,11 @@ import { ReactElement } from 'react'; * @returns {ReactElement} The UnsupportedBrowserMessage component. */ const UnsupportedBrowserMessage = (): ReactElement => { - const header = 'Your browser is unsupported'; - const message = 'Please use one of our supported browsers.'; - + const { t } = useTranslation(); return (
-

{header}

-

{message}

+

{t('unsupportedBrowser.header')}

+

{t('unsupportedBrowser.message')}

); }; diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx index be05d055..9e0bc4e4 100644 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx @@ -1,5 +1,6 @@ import { ReactElement, useCallback, useEffect, useState } from 'react'; import { Box, Button, Typography, useMediaQuery } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import EffectOptionButtons from '../../../BackgroundEffects/EffectOptionButtons/EffectOptionButtons'; import BackgroundGallery from '../../../BackgroundEffects/BackgroundGallery/BackgroundGallery'; import BackgroundVideoContainer from '../../../BackgroundEffects/BackgroundVideoContainer'; @@ -27,6 +28,7 @@ const BackgroundEffectsLayout = ({ isOpen, handleClose, }: BackgroundEffectsProps): ReactElement | false => { + const { t } = useTranslation(); const [backgroundSelected, setBackgroundSelected] = useState('none'); const { publisher, changeBackground, isVideoEnabled } = usePreviewPublisherContext(); const { publisherVideoElement, changeBackground: changeBackgroundPreview } = @@ -69,7 +71,7 @@ const BackgroundEffectsLayout = ({ handleClose(); }} > - Cancel + {t('button.cancel')} ); @@ -86,7 +88,7 @@ const BackgroundEffectsLayout = ({ isOpen && ( <> - Background Effects + {t('backgroundEffects.title')} @@ -101,7 +103,7 @@ const BackgroundEffectsLayout = ({ - Choose Background Effect + {t('backgroundEffects.choice')} { + const { t } = useTranslation(); const { isVideoEnabled, toggleVideo } = usePreviewPublisherContext(); const { toggleVideo: toggleBackgroundVideoPublisher } = useBackgroundPublisherContext(); - const title = `Turn ${isVideoEnabled ? 'off' : 'on'} camera`; + const title = isVideoEnabled + ? t('devices.video.camera.state.off') + : t('devices.video.camera.state.on'); const handleToggleVideo = () => { toggleVideo(); @@ -36,7 +40,7 @@ const CameraButton = (): ReactElement => { overflow: 'hidden', }} > - + { + const { t } = useTranslation(); const isSmallViewport = useIsSmallViewport(); const { allMediaDevices } = useDevices(); const { localAudioSource, localVideoSource, changeAudioSource, changeVideoSource } = @@ -64,6 +66,7 @@ const ControlPanel = ({ textTransform: 'none', // ensures that the text is not upper case border: 'none', boxShadow: 'none', + whiteSpace: 'nowrap', '&:hover': { border: 'none', boxShadow: 'none', @@ -83,7 +86,9 @@ const ControlPanel = ({ aria-expanded={openVideoInput ? 'true' : undefined} onClick={handleAudioInputOpen} > - {isSmallViewport ? 'Mic' : 'Microphone'} + {isSmallViewport + ? t('devices.audio.microphone.short') + : t('devices.audio.microphone.full')} } - aria-label="video" + aria-label={t('devices.video.camera.button.ariaLabel')} > - Camera + {t('button.camera')} } > - Speaker + {t('button.speaker')} { + const { t } = useTranslation(); const { isAudioEnabled, toggleAudio } = usePreviewPublisherContext(); - const title = `Turn ${isAudioEnabled ? 'off' : 'on'} microphone`; + const title = isAudioEnabled + ? t('devices.audio.microphone.state.off') + : t('devices.audio.microphone.state.on'); return ( { transition: 'transform 0.2s ease-in-out', }} > - + { + const { t } = useTranslation(); const { setUser } = useUserContext(); const navigate = useNavigate(); const roomName = useRoomName(); @@ -95,16 +97,18 @@ const UsernameInput = ({ username, setUsername }: UserNameInputProps): ReactElem
-
Prepare to join:
+
{t('waitingRoom.title')}

{roomName}

-
What is your name?
+
+ {t('waitingRoom.user.input.label')} +
- Join + {t('button.join')}
diff --git a/frontend/src/hooks/useArchives.tsx b/frontend/src/hooks/useArchives.tsx index 01f9dcbb..435c209e 100644 --- a/frontend/src/hooks/useArchives.tsx +++ b/frontend/src/hooks/useArchives.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ArchiveResponse, getArchives } from '../api/archiving'; import { Archive } from '../api/archiving/model'; @@ -12,6 +13,7 @@ export type UseArchivesProps = { * @returns {Archive[] | 'error'} An array of Archives, or the text, `error`. */ const useArchives = ({ roomName }: UseArchivesProps): Archive[] | 'error' => { + const { i18n } = useTranslation(); const [archives, setArchives] = useState([]); const pollingIntervalRef = useRef | undefined>(undefined); @@ -20,7 +22,7 @@ const useArchives = ({ roomName }: UseArchivesProps): Archive[] | 'error' => { if (roomName) { let archiveData: ArchiveResponse; try { - archiveData = await getArchives(roomName); + archiveData = await getArchives(i18n.language, roomName); } catch (error: unknown) { const message = error instanceof Error ? error.message : error; console.error(`Error retrieving archive: ${message}`); @@ -45,7 +47,7 @@ const useArchives = ({ roomName }: UseArchivesProps): Archive[] | 'error' => { clearInterval(pollingIntervalRef.current); } }; - }, [roomName]); + }, [roomName, i18n.language]); return archives; }; diff --git a/frontend/src/hooks/useChat.tsx b/frontend/src/hooks/useChat.tsx index 35e5c6ef..92b85072 100644 --- a/frontend/src/hooks/useChat.tsx +++ b/frontend/src/hooks/useChat.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import useUserContext from './useUserContext'; import { ChatMessageType } from '../types/chat'; import { SignalType } from '../types/session'; @@ -22,6 +23,7 @@ export type UseChat = { * @property {(text: string) => void} sendChatMessage - function to send message */ const useChat = ({ signal }: UseChatProps): UseChat => { + const { t } = useTranslation(); const [messages, setMessages] = useState([]); const { user: { @@ -46,21 +48,24 @@ const useChat = ({ signal }: UseChatProps): UseChat => { [signal, localParticipantName] ); - const onChatMessage = useCallback((data: string) => { - if (data) { - try { - const { text, participantName } = JSON.parse(data); - const message: ChatMessageType = { - timestamp: Date.now(), - participantName: `${participantName || 'unknown user'}`, - message: text, - }; - setMessages((prev) => [...prev, message]); - } catch (err) { - console.log(err); + const onChatMessage = useCallback( + (data: string) => { + if (data) { + try { + const { text, participantName } = JSON.parse(data); + const message: ChatMessageType = { + timestamp: Date.now(), + participantName: participantName || t('user.unknown'), + message: text, + }; + setMessages((prev) => [...prev, message]); + } catch (err) { + console.log(err); + } } - } - }, []); + }, + [t] + ); return { messages, diff --git a/frontend/src/hooks/useDateTime.tsx b/frontend/src/hooks/useDateTime.tsx index d2515902..5604a9e4 100644 --- a/frontend/src/hooks/useDateTime.tsx +++ b/frontend/src/hooks/useDateTime.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { getFormattedDate, getFormattedTime } from '../utils/dateTime'; - /** * @typedef {object} UseDateTimeType * @property {string} date - The condensed date (e.g., "Wed, Jun 26") @@ -12,24 +12,24 @@ import { getFormattedDate, getFormattedTime } from '../utils/dateTime'; * @returns {UseDateTimeType} the date and time */ const useDateTime = () => { + const { i18n } = useTranslation(); const [date, setDate] = useState('Wed, Jun 26'); const [time, setTime] = useState('5:01 PM'); - /** * Gets the current time and sets it in the format of "6:29 PM". */ - const updateTime = () => { - const formattedTime = getFormattedTime(); + const updateTime = useCallback(() => { + const formattedTime = getFormattedTime(i18n.language); setTime(formattedTime); - }; + }, [i18n.language]); /** * Gets the current date and sets it in the format of "Wed, Jun 26". */ - const updateDate = () => { - const formattedDate = getFormattedDate(); + const updateDate = useCallback(() => { + const formattedDate = getFormattedDate(i18n.language); setDate(formattedDate); - }; + }, [i18n.language]); /** * Re-sets the time every second. @@ -37,13 +37,13 @@ const useDateTime = () => { const changeTime = useCallback(() => { const interval = setInterval(updateTime, 1_000); return () => clearInterval(interval); - }, []); + }, [updateTime]); useEffect(() => { updateTime(); updateDate(); changeTime(); - }, [changeTime]); + }, [changeTime, updateTime, updateDate]); return { date, diff --git a/frontend/src/hooks/useDevices.tsx b/frontend/src/hooks/useDevices.tsx index bdcfa202..3e899713 100644 --- a/frontend/src/hooks/useDevices.tsx +++ b/frontend/src/hooks/useDevices.tsx @@ -6,6 +6,7 @@ import { OTError, AudioOutputDevice, } from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; import { AllMediaDevices } from '../types'; import isAudioInputDevice from '../utils/isAudioInputDevice'; import isVideoInputDevice from '../utils/isVideoInputDevice'; @@ -19,6 +20,7 @@ import renameDefaultAudioOutputDevice from '../utils/renameDefaultAudioOutputDev * - @property {() => void} getAllMediaDevices - function to trigger update of device in allMediaDevices. It is to be called when user has given device permissions. */ const useDevices = () => { + const { t } = useTranslation(); const { mediaDevices } = window.navigator; const [allMediaDevices, setAllMediaDevices] = useState({ @@ -48,7 +50,9 @@ const useDevices = () => { // Vonage Video API's getAudioOutputDevices retrieves all audio output devices (speakers) let audioOutputDevices: AudioOutputDevice[] = await getAudioOutputDevices(); // Rename the label of the default audio output to "System Default" - audioOutputDevices = audioOutputDevices.map(renameDefaultAudioOutputDevice); + audioOutputDevices = audioOutputDevices.map((device) => + renameDefaultAudioOutputDevice(device, t('devices.audio.defaultLabel')) + ); // Filter audio input devices from the list retrieved by Vonage Video API's getDevices const audioInputDevices = devices?.filter(isAudioInputDevice) || []; @@ -64,7 +68,7 @@ const useDevices = () => { }); }); }); - }, [mediaDevices.enumerateDevices]); + }, [mediaDevices.enumerateDevices, t]); /* * It is important to add a device change listener that is available by the browsers. diff --git a/frontend/src/hooks/useScreenShare.tsx b/frontend/src/hooks/useScreenShare.tsx index d2dc8750..5caac8c6 100644 --- a/frontend/src/hooks/useScreenShare.tsx +++ b/frontend/src/hooks/useScreenShare.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useCallback } from 'react'; import { Publisher, initPublisher } from '@vonage/client-sdk-video'; +import { useTranslation } from 'react-i18next'; import useSessionContext from './useSessionContext'; import useUserContext from './useUserContext'; @@ -20,6 +21,7 @@ export type UseScreenShareType = { * @returns {UseScreenShareType} useScreenShare */ const useScreenShare = (): UseScreenShareType => { + const { t } = useTranslation(); const { vonageVideoClient, unpublish, publish } = useSessionContext(); const { user } = useUserContext(); @@ -60,7 +62,7 @@ const useScreenShare = (): UseScreenShareType => { videoSource: 'screen', insertDefaultUI: false, videoContentHint: 'detail', - name: `${user.defaultSettings.name}'s screen`, + name: t('participants.screen', { participantName: user.defaultSettings.name }), }, (err) => { if (err) { @@ -107,6 +109,7 @@ const useScreenShare = (): UseScreenShareType => { unpublishScreenshare, onScreenShareStopped, publish, + t, ]); return { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts new file mode 100644 index 00000000..326262fb --- /dev/null +++ b/frontend/src/i18n.ts @@ -0,0 +1,17 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import resources from './locales'; + +i18n + // detect user language: https://github.com/i18next/i18next-browser-languageDetector + .use(LanguageDetector) + .use(initReactI18next) + // for all options read: https://www.i18next.com/overview/configuration-options + .init({ + fallbackLng: import.meta.env.VITE_I18N_FALLBACK_LANGUAGE ?? 'en', + supportedLngs: import.meta.env.VITE_I18N_SUPPORTED_LANGUAGES?.split('|') ?? ['en'], + resources, + }); + +export default i18n; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 00000000..a54cb72f --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,153 @@ +{ + "archiveList.archive.createdAt": "Started at: {{createdAt}}", + "archiveList.archive.index": "Recording {{index}}", + "archiveList.download.tooltip": "Download recording {{id}}", + "archiveList.empty": "There are no recordings for this meeting", + "archiveList.error.text": "There was an error loading recordings for this meeting", + "archiveList.error.tooltip": "This recording failed or is expired", + "archiveList.label": "Recordings", + "archiveList.loading.tooltip": "This recording is not ready for download yet", + "backgroundEffects.ariaLabel": "background effects", + "backgroundEffects.choice": "Choose Background Effect", + "backgroundEffects.limit": "You have reached the maximum custom images limit", + "backgroundEffects.recommended.note": "Note: Images are stored only locally in the browser.", + "backgroundEffects.recommended.specs": "Recommended: JPG/PNG img. at 1280x720 resolution.", + "backgroundEffects.title": "Background Effects", + "backgroundEffects.video.disabled": "You have not enabled video", + "button.apply": "Apply", + "button.camera": "Camera", + "button.cancel": "Cancel", + "button.close": "Close", + "button.createRoom": "Create room", + "button.join": "Join", + "button.mute": "Mute", + "button.send": "Send", + "button.speaker": "Speaker", + "captions.ariaLabel": "captions button", + "captions.disable": "Disable captions", + "captions.enable": "Enable captions", + "captions.errors": "Captions error: {{captionsErrorResponse}}", + "chat.ariaLabel": "toggle chat", + "chat.close": "Close chat", + "chat.copied": "Copied", + "chat.copy": "Copy to clipboard", + "chat.input.placeholder": "Send a message", + "chat.meetingUrl": "Meeting URL:", + "chat.open": "Open chat", + "chat.title": "Chat", + "common.or": "or", + "connectionAlert.quality.message": "Please check your connectivity. Your video may be disabled to improve the user experience", + "connectionAlert.quality.title": "Video quality problem", + "connectionAlert.reconnecting.message": "Please verify your network connection", + "connectionAlert.reconnecting.title": "Lost connection", + "deviceAccessAlert.askDeviceMessage": "To join the video room, your browser will request access to your camera and microphone.", + "deviceAccessAlert.deniedDeviceMessage": "It seems your browser is blocked from accessing your camera and/or microphone. Reset the permission state through your browser's UI.", + "deviceAccessAlert.imageAlt": "Access Dialog", + "devices.audio.ariaLabel": "audio devices dropdown", + "devices.audio.defaultLabel": "System Default", + "devices.audio.disable": "Disable microphone", + "devices.audio.enable": "Enable microphone", + "devices.audio.microphone.ariaLabel": "toggle audio", + "devices.audio.microphone.full": "Microphone", + "devices.audio.microphone.short": "Mic", + "devices.audio.microphone.state.on": "Enable microphone", + "devices.audio.microphone.state.off": "Disable microphone", + "devices.audio.noiseSuppression": "Advanced Noise Suppression", + "devices.audio.speakers.full": "Speakers", + "devices.buttons.ariaLabel": "split button", + "devices.settings.ariaLabel": "device settings", + "devices.video.ariaLabel": "video devices dropdown", + "devices.video.blur.ariaLabel": "Toggle background blur", + "devices.video.blur.label": "Blur your background", + "devices.video.camera.ariaLabel": "toggle video", + "devices.video.camera.button.ariaLabel": "video", + "devices.video.camera.state.on": "Enable camera", + "devices.video.camera.state.off": "Disable camera", + "devices.video.camera.full": "Camera", + "devices.video.disable": "Disable video", + "devices.video.enable": "Enable video", + "emoji.ariaLabel": "open sendable emoji menu", + "emoji.tooltip": "Express yourself", + "errors.unknown": "Unknown error occurred", + "feedbackForm.ariaLabel": "toggle report issue form", + "feedbackForm.close": "Close report issue form", + "feedbackForm.disclaiamer.label": "Please do not include any sensitive information.", + "feedbackForm.disclaiamer.screenshot": "A screenshot will help us better understand the issue. (optional)", + "feedbackForm.field.issue.label": "Describe your issue", + "feedbackForm.field.issue.type": "Description", + "feedbackForm.field.name.label": "Tell us your name", + "feedbackForm.field.name.type": "Your name", + "feedbackForm.field.title.label": "When you noticed this issue, what were you trying to do?", + "feedbackForm.field.title.type": "Title", + "feedbackForm.helperText.error": "{{field}} is required and must be less than {{limit}} characters", + "feedbackForm.open": "Open report issue form", + "feedbackForm.submitted.content": "We greatly appreciate your input, as it helps us improve the app and address any issues.", + "feedbackForm.submitted.error": "Something went wrong. Please try again.", + "feedbackForm.submitted.thanks": "Thank you for your feedback!", + "feedbackForm.submitted.track": "Track your progress here.", + "feedbackForm.title": "Report Issue", + "filePicker.addScreenshot": "Add screenshot", + "filePicker.attachedScreenshot": "Attached screenshot", + "filePicker.capture": "Capture screenshot", + "filePicker.delete": "Delete screenshot", + "filePicker.sizeLimit": "The maximum upload size is 20MB. Please upload another file.", + "githubTooltip": "Visit our GitHub Repo", + "goodBye.back": "Return to landing page", + "goodBye.reEnter": "Re-enter", + "goodbye.default.header": "You left the room", + "goodbye.default.message": "We hope you had fun", + "landing.welcome.subtitle": "Create a new room or join an existing one.", + "landing.welcome.title": "Welcome to the Vonage Video React App", + "layout.tooltip.isPinningPresent": "Cannot switch layout while a participant is pinned", + "layout.tooltip.isScreenSharePresent": "Cannot switch layout while screen share is active", + "layout.tooltip.switchToActiveSpeaker": "Switch to Active Speaker layout", + "layout.tooltip.switchToGrid": "Switch to Grid layout", + "mutedAlert.message.forceMuted": "You have been muted by another participant. Click on the mic to unmute yourself.", + "mutedAlert.message.muted": "Are you talking? Your mic is off. Click on the mic to turn it on.", + "participants.list.ariaLabel": "toggle participant list", + "participants.list.close": "Close participant list", + "participants.list.open": "Open participant list", + "participants.maxPin": "You can't pin any more tiles", + "participants.mute.dialog.content": "Mute {{participantName}} for everyone in the call? Only {{participantName}} can unmute themselves.", + "participants.mute.tooltip": "Mute {{participantName}}'s microphone", + "participants.pin": "Pin {{participantName}}", + "participants.pin.video": "Pin {{participantName}}'s video", + "participants.screen": "{{participantName}}'s screen", + "participants.title": "Participants", + "participants.unpin": "Unpin {{participantName}}", + "participants.unpin.video": "Unpin {{participantName}}'s video", + "publishingErrors.accessDenied.message": "It seems your browser is blocked from accessing your {{device}}. Reset the permission state through your browser's UI.", + "publishingErrors.accessDenied.title": "{{device}} access is denied", + "publishingErrors.blocked.message": "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", + "publishingErrors.blocked.title": "Difficulties joining room", + "recording.start.dialog.content": "Make sure everyone is ready! You can download the recording from the \"Goodbye\" page after you leave the room.", + "recording.start.dialog.title": "Start Recording?", + "recording.start.title": "Start recording", + "recording.stop.dialog.content": "You can download the recording from the \"Goodbye\" page after you leave the room.", + "recording.stop.dialog.title": "Stop Recording?", + "recording.stop.title": "Stop recording", + "recording.tooltip.ariaLabel": "video layout", + "room.exit.ariaLabel": "exit", + "room.exit.tooltip": "Exit meeting", + "room.input.helper": "No spaces or special characters allowed", + "room.input.placeholder": "Enter room name", + "screenSharing.dialog.action": "Start sharing your screen", + "screenSharing.dialog.content": "Looks like there is someone else sharing their screen. If you continue, their screen is no longer going to be shared.", + "screenSharing.dialog.title": "Do you want to share your screen?", + "screenSharing.title.start": "Start screen share", + "screenSharing.title.stop": "Stop screen share", + "screenSharing.tooltip.ariaLabel": "Start or stop screen share", + "soundTest.start": "Test speakers", + "soundTest.stop": "Stop testing", + "toolbar.tooltip.ariaLabel": "open additional toolbar items menu", + "toolbar.tooltip.title": "Access additional toolbar items", + "unsupportedBrowser.header": "Your browser is unsupported", + "unsupportedBrowser.message": "Please use one of our supported browsers.", + "unsupportedBrowser.supported.downloadLink": "Download link for {{browser}}", + "unsupportedBrowser.supported.title": "Supported browsers:", + "user.unknown": "unknown user", + "user.you": "You", + "waitingRoom.title": "Prepare to join:", + "waitingRoom.user.input.label": "What is your name?", + "waitingRoom.user.input.placeholder": "Enter your name" +} diff --git a/frontend/src/locales/index.ts b/frontend/src/locales/index.ts new file mode 100644 index 00000000..c18e928f --- /dev/null +++ b/frontend/src/locales/index.ts @@ -0,0 +1,7 @@ +import EN from './en.json'; + +export default { + en: { + translation: EN, + }, +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8fea6425..e3ec06b0 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import ReactDOM from 'react-dom/client'; import App from './App.jsx'; +import './i18n.js'; /** * The root HTML element where the React application is rendered. diff --git a/frontend/src/pages/GoodBye/GoodBye.tsx b/frontend/src/pages/GoodBye/GoodBye.tsx index f220ce84..0669bea1 100644 --- a/frontend/src/pages/GoodBye/GoodBye.tsx +++ b/frontend/src/pages/GoodBye/GoodBye.tsx @@ -1,5 +1,6 @@ import { useLocation } from 'react-router-dom'; import { ReactElement } from 'react'; +import { useTranslation } from 'react-i18next'; import Banner from '../../components/Banner'; import useArchives from '../../hooks/useArchives'; import ArchiveList from '../../components/GoodBye/ArchiveList'; @@ -17,14 +18,15 @@ import useRoomName from '../../hooks/useRoomName'; * @returns {ReactElement} - the goodbye page. */ const GoodBye = (): ReactElement => { + const { t } = useTranslation(); const width = window.innerWidth < 800 ? '100%' : '800px'; const location = useLocation(); const roomName = useRoomName({ useLocationState: true, }); const archives = useArchives({ roomName }); - const header: string = location.state?.header || 'You left the room'; - const caption: string = location.state?.caption || 'We hope you had fun'; + const header: string = location.state?.header || t('goodbye.default.header'); + const caption: string = location.state?.caption || t('goodbye.default.message'); return (
@@ -40,7 +42,7 @@ const GoodBye = (): ReactElement => {
-

Recordings

+

{t('archiveList.label')}

diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx index 674e3c6c..55314385 100644 --- a/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx +++ b/frontend/src/pages/MeetingRoom/MeetingRoom.spec.tsx @@ -19,7 +19,7 @@ import useLayoutManager, { GetLayout } from '../../hooks/useLayoutManager'; import useSessionContext from '../../hooks/useSessionContext'; import useActiveSpeaker from '../../hooks/useActiveSpeaker'; import useScreenShare, { UseScreenShareType } from '../../hooks/useScreenShare'; -import { PUBLISHING_BLOCKED_CAPTION, RIGHT_PANEL_BUTTON_COUNT } from '../../utils/constants'; +import { RIGHT_PANEL_BUTTON_COUNT } from '../../utils/constants'; import useToolbarButtons, { UseToolbarButtons, UseToolbarButtonsProps, @@ -356,7 +356,8 @@ describe('MeetingRoom', () => { it('should redirect user to goodbye page if unable to publish', () => { const publishingBlockedError = { header: 'Difficulties joining room', - caption: PUBLISHING_BLOCKED_CAPTION, + caption: + "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", }; publisherContext.publishingError = publishingBlockedError; render(); @@ -366,7 +367,8 @@ describe('MeetingRoom', () => { state: { header: 'Difficulties joining room', roomName: 'test-room-name', - caption: PUBLISHING_BLOCKED_CAPTION, + caption: + "We're having trouble connecting you with others in the meeting room. Please check your network and try again.", }, }); }); diff --git a/frontend/src/pages/MeetingRoom/MeetingRoom.tsx b/frontend/src/pages/MeetingRoom/MeetingRoom.tsx index b23da15d..b9986250 100644 --- a/frontend/src/pages/MeetingRoom/MeetingRoom.tsx +++ b/frontend/src/pages/MeetingRoom/MeetingRoom.tsx @@ -1,5 +1,6 @@ import { useEffect, ReactElement, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import usePublisherContext from '../../hooks/usePublisherContext'; import ConnectionAlert from '../../components/MeetingRoom/ConnectionAlert'; import Toolbar from '../../components/MeetingRoom/Toolbar'; @@ -30,6 +31,7 @@ const height = '@apply h-[calc(100dvh_-_80px)]'; * @returns {ReactElement} - The meeting room. */ const MeetingRoom = (): ReactElement => { + const { t } = useTranslation(); const roomName = useRoomName(); const { publisher, publish, quality, initializeLocalPublisher, publishingError, isVideoEnabled } = usePublisherContext(); @@ -164,16 +166,16 @@ const MeetingRoom = (): ReactElement => { /> {reconnecting && ( )} {!reconnecting && quality !== 'good' && isVideoEnabled && ( )} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 163881ca..a9a7aec0 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -2,6 +2,7 @@ import '../css/index.css'; import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; +import '../i18n'; afterEach(() => { cleanup(); diff --git a/frontend/src/utils/constants.tsx b/frontend/src/utils/constants.tsx index 0a440d6d..a7dfe1f1 100644 --- a/frontend/src/utils/constants.tsx +++ b/frontend/src/utils/constants.tsx @@ -21,24 +21,6 @@ export const DEVICE_ACCESS_STATUS = { ACCESS_CHANGED: 'accessChanged', }; -/** - * @constant {string} MUTED_ALERT_MESSAGE - A message to alert the user that their microphone is muted. - */ -export const MUTED_ALERT_MESSAGE = - 'Are you talking? Your mic is off. Click on the mic to turn it on.'; - -/** - * @constant {string} FORCE_MUTED_ALERT_MESSAGE - A message to alert the user that their microphone was muted by another participant. - */ -export const FORCE_MUTED_ALERT_MESSAGE = - 'You have been muted by another participant. Click on the mic to unmute yourself.'; - -/** - * @constant {string} PUBLISHING_BLOCKED_CAPTION - A user-friendly message alerting the user of publishing issues. - */ -export const PUBLISHING_BLOCKED_CAPTION = - "We're having trouble connecting you with others in the meeting room. Please check your network and try again."; - /** * @constant {string} TEXT_SHADOW - The text shadow style used for display purposes. */ diff --git a/frontend/src/utils/dateTime.spec.ts b/frontend/src/utils/dateTime.spec.ts new file mode 100644 index 00000000..d413682a --- /dev/null +++ b/frontend/src/utils/dateTime.spec.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { getFormattedDate, getFormattedTime } from './dateTime'; + +const timestamp = new Date('December 1, 2025 21:12:00').getTime(); + +describe('dateTime', () => { + it('Should render date properly with locale', () => { + expect(getFormattedDate('en', timestamp)).toEqual('Mon, Dec 1'); + expect(getFormattedDate('fr', timestamp)).toEqual('lun. 1 déc.'); + }); + + it('Should render time properly with locale', () => { + expect(getFormattedTime('en', timestamp)).toEqual('9:12 PM'); + expect(getFormattedTime('fr', timestamp)).toEqual('21:12'); + }); +}); diff --git a/frontend/src/utils/dateTime.ts b/frontend/src/utils/dateTime.ts index 33717349..c8d54b6d 100644 --- a/frontend/src/utils/dateTime.ts +++ b/frontend/src/utils/dateTime.ts @@ -1,46 +1,28 @@ /** - * Gets the current time and sets it in the format of "6:29 PM". + * Gets the current time and sets it in the format relative to current locale. + * @param {string} locale - current locale * @param {number} [timestamp] - optional timestamp, if omitted uses current system time * @returns {string} formatted time */ -export const getFormattedTime = (timestamp?: number) => { +export const getFormattedTime = (locale: string, timestamp?: number) => { const dateTime = timestamp ? new Date(timestamp) : new Date(); - - let hours = dateTime.getHours(); - const amOrPm = hours >= 12 ? 'PM' : 'AM'; - hours %= 12; // Converts from 24h format to 12h - hours = hours || 12; // If midnight, it's really 12 not 0 - - let minutes: number | string = dateTime.getMinutes(); - minutes = minutes < 10 ? `0${minutes}` : minutes; - return `${hours}:${minutes} ${amOrPm}`; + return dateTime.toLocaleTimeString(locale, { + hour: 'numeric', + minute: 'numeric', + }); }; -const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; -const monthsOfYear = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; - /** - * Gets the current date and sets it in the format of "Wed, Jun 26". + * Gets the current date and sets it in the format relative to current locale. + * @param {string} locale - current locale * @param {number} timestamp - optional timestamp. If omitted used current system time * @returns {number} formatted date */ -export const getFormattedDate = (timestamp?: number) => { +export const getFormattedDate = (locale: string, timestamp?: number) => { const date = timestamp ? new Date(timestamp) : new Date(); - const dayOfWeek = daysOfWeek[date.getDay()]; - const monthOfYear = monthsOfYear[date.getMonth()]; - const dayOfMonth = date.getDate(); - return `${dayOfWeek}, ${monthOfYear} ${dayOfMonth}`; + return date.toLocaleDateString(locale, { + month: 'short', + weekday: 'short', + day: 'numeric', + }); }; diff --git a/frontend/src/utils/getAccessDeniedError/getAccessDeniedError.spec.ts b/frontend/src/utils/getAccessDeniedError/getAccessDeniedError.spec.ts deleted file mode 100644 index bd22e14a..00000000 --- a/frontend/src/utils/getAccessDeniedError/getAccessDeniedError.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import getAccessDeniedError from './getAccessDeniedError'; - -describe('getAccessDeniedError', () => { - ['Camera', 'Microphone'].forEach((device) => { - it(`returns an accessDenied error message for ${device}`, () => { - const accessDeniedError = getAccessDeniedError(device); - - expect(accessDeniedError?.header).toEqual(`${device} access is denied`); - expect(accessDeniedError?.caption).toEqual( - `It seems your browser is blocked from accessing your ${device.toLowerCase()}. Reset the permission state through your browser's UI.` - ); - }); - }); -}); diff --git a/frontend/src/utils/getAccessDeniedError/getAccessDeniedError.ts b/frontend/src/utils/getAccessDeniedError/getAccessDeniedError.ts deleted file mode 100644 index 2e8ab4ec..00000000 --- a/frontend/src/utils/getAccessDeniedError/getAccessDeniedError.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * @typedef PublishingErrorType - Represents an error message displaying a header and a caption or null if there is no error. - * @property {string} header - The main title of the error message. - * @property {string} caption - Additional context for the error. - */ -export type PublishingErrorType = { - header: string; - caption: string; -} | null; - -export default (device: string): PublishingErrorType => ({ - header: `${device} access is denied`, - caption: `It seems your browser is blocked from accessing your ${device.toLowerCase()}. Reset the permission state through your browser's UI.`, -}); diff --git a/frontend/src/utils/getAccessDeniedError/index.ts b/frontend/src/utils/getAccessDeniedError/index.ts deleted file mode 100644 index f56b3f34..00000000 --- a/frontend/src/utils/getAccessDeniedError/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import getAccessDeniedError from './getAccessDeniedError'; - -export default getAccessDeniedError; diff --git a/frontend/src/utils/renameDefaultAudioOutputDevice/renameDefaultAudioOutputDevice.ts b/frontend/src/utils/renameDefaultAudioOutputDevice/renameDefaultAudioOutputDevice.ts index 2674ac49..4567501a 100644 --- a/frontend/src/utils/renameDefaultAudioOutputDevice/renameDefaultAudioOutputDevice.ts +++ b/frontend/src/utils/renameDefaultAudioOutputDevice/renameDefaultAudioOutputDevice.ts @@ -3,7 +3,11 @@ import { AudioOutputDevice } from '@vonage/client-sdk-video'; /** * Helper function to rename a deviceId to `System Default` for any audio output devices * @param {AudioOutputDevice} audioOutput - The device to check and rename. + * @param {string} defaultLabel - Default label translated * @returns {AudioOutputDevice} - The renamed device or the original device */ -export default (audioOutput: AudioOutputDevice): AudioOutputDevice => - audioOutput.deviceId === 'default' ? { ...audioOutput, label: 'System Default' } : audioOutput; +export default ( + audioOutput: AudioOutputDevice, + defaultLabel = 'System Default' +): AudioOutputDevice => + audioOutput.deviceId === 'default' ? { ...audioOutput, label: defaultLabel } : audioOutput; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 0dc40558..dd320609 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -17,9 +17,17 @@ "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, - "noFallthroughCasesInSwitch": true, + "noFallthroughCasesInSwitch": true }, - "include": ["./src", "./src/types", "src/pages", "src/helpers", "./.eslintrc.cjs", "**/*.spec.ts", "**/*.spec.tsx"], + "include": [ + "./src", + "./src/types", + "src/pages", + "src/helpers", + "./.eslintrc.cjs", + "**/*.spec.ts", + "**/*.spec.tsx" + ], "exclude": ["./node_modules", "./vite.config.ts"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/frontend/tsdoc.json b/frontend/tsdoc.json index f3eafc6b..2f900508 100644 --- a/frontend/tsdoc.json +++ b/frontend/tsdoc.json @@ -1,8 +1,6 @@ { "$schema": "https://developer.microsoft.com/json-schemas/tsdoc/v0/tsdoc.schema.json", - "extends": [ - "typedoc/tsdoc.json" - ], + "extends": ["typedoc/tsdoc.json"], "noStandardTags": false, "tagDefinitions": [ { diff --git a/frontend/typedoc.json b/frontend/typedoc.json index 4d7c5489..75f75d13 100644 --- a/frontend/typedoc.json +++ b/frontend/typedoc.json @@ -1,8 +1,5 @@ { - "entryPoints": [ - "./src/**/*.tsx", - "./src/**/*.ts", - ], + "entryPoints": ["./src/**/*.tsx", "./src/**/*.ts"], "exclude": [ "./src/**/*.spec.ts", "./src/**/*.spec.tsx", diff --git a/yarn.lock b/yarn.lock index fb917853..a5dfd550 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,7 +425,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.25.9" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.6", "@babel/runtime@^7.26.10", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.6", "@babel/runtime@^7.26.10", "@babel/runtime@^7.27.6", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": version "7.28.3" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a" integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA== @@ -1786,105 +1786,105 @@ estree-walker "^2.0.2" picomatch "^2.3.1" -"@rollup/rollup-android-arm-eabi@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz#9241b59af721beb7e3587a56c6c245d6c465753d" - integrity sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw== - -"@rollup/rollup-android-arm64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz#f70ee53ba991fdd65c277b0716c559736d490a58" - integrity sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA== - -"@rollup/rollup-darwin-arm64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz#9f59000e817cf5760d87515ce899f8b93fe8756a" - integrity sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A== - -"@rollup/rollup-darwin-x64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz#c92aebd02725ae1b88bdce40f08f7823e8055c78" - integrity sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg== - -"@rollup/rollup-freebsd-arm64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz#b128dbe7b353922ddd729a4fc4e408ddcbf338b5" - integrity sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ== - -"@rollup/rollup-freebsd-x64@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz#88297a0ddfadddd61d7d9b73eb42b3f227301d30" - integrity sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg== - -"@rollup/rollup-linux-arm-gnueabihf@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz#a59afc092523ebe43d3899f33da9cdd2ec01fb87" - integrity sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw== - -"@rollup/rollup-linux-arm-musleabihf@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz#3095c1327b794bd187d03e372e633717fb69b4c0" - integrity sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw== - -"@rollup/rollup-linux-arm64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz#e43bb77df3a6de85312e991d1e3ad352d1abb00d" - integrity sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA== - -"@rollup/rollup-linux-arm64-musl@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz#34873a437bcd87618f702dc66f0cbce170aebf9f" - integrity sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA== - -"@rollup/rollup-linux-loongarch64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz#224ff524349e365baa56f1f512822548c2d76910" - integrity sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg== - -"@rollup/rollup-linux-powerpc64le-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz#43c3c053b26ace18a1d3dab204596a466c1b0e34" - integrity sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw== - -"@rollup/rollup-linux-riscv64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz#e7df825d71daefa7037605015455aa58be43cd7a" - integrity sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g== - -"@rollup/rollup-linux-riscv64-musl@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz#d76ad93a7f4c0b2855a024d8d859196acf38acf5" - integrity sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q== - -"@rollup/rollup-linux-s390x-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz#0852608843d05852af3f447bf43bb63d80d62b6a" - integrity sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw== - -"@rollup/rollup-linux-x64-gnu@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz#d16a57f86357a4e697142bee244afed59b24e6c5" - integrity sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ== - -"@rollup/rollup-linux-x64-musl@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz#51cbc8b1eb46ebc0e284725418b6fbf48686e4e2" - integrity sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ== - -"@rollup/rollup-win32-arm64-msvc@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz#d6d84aace2b211119bf0ab1c586e29d01e32aa01" - integrity sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw== - -"@rollup/rollup-win32-ia32-msvc@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz#4af33168de2f65b97a8f36bd1d8d21cea34d3ccb" - integrity sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw== - -"@rollup/rollup-win32-x64-msvc@4.43.0": - version "4.43.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz#42a88207659e404e8ffa655cae763cbad94906ab" - integrity sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw== +"@rollup/rollup-android-arm-eabi@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz#292e25953d4988d3bd1af0f5ebbd5ee4d65c90b4" + integrity sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA== + +"@rollup/rollup-android-arm64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz#053b3def3451e6fc1a9078188f22799e868d7c59" + integrity sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ== + +"@rollup/rollup-darwin-arm64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz#98d90445282dec54fd05440305a5e8df79a91ece" + integrity sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ== + +"@rollup/rollup-darwin-x64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz#fe05f95a736423af5f9c3a59a70f41ece52a1f20" + integrity sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA== + +"@rollup/rollup-freebsd-arm64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz#41e1fbdc1f8c3dc9afb6bc1d6e3fb3104bd81eee" + integrity sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg== + +"@rollup/rollup-freebsd-x64@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz#69131e69cb149d547abb65ef3b38fc746c940e24" + integrity sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw== + +"@rollup/rollup-linux-arm-gnueabihf@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz#977ded91c7cf6fc0d9443bb9c0a064e45a805267" + integrity sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA== + +"@rollup/rollup-linux-arm-musleabihf@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz#dc034fc3c0f0eb5c75b6bc3eca3b0b97fd35f49a" + integrity sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ== + +"@rollup/rollup-linux-arm64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz#5e92613768d3de3ffcabc965627dd0a59b3e7dfc" + integrity sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng== + +"@rollup/rollup-linux-arm64-musl@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz#2a44f88e83d28b646591df6e50aa0a5a931833d8" + integrity sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg== + +"@rollup/rollup-linux-loongarch64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz#bd5897e92db7fbf7dc456f61d90fff96c4651f2e" + integrity sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA== + +"@rollup/rollup-linux-ppc64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz#a7065025411c14ad9ec34cc1cd1414900ec2a303" + integrity sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw== + +"@rollup/rollup-linux-riscv64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz#17f9c0c675e13ef4567cfaa3730752417257ccc3" + integrity sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ== + +"@rollup/rollup-linux-riscv64-musl@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz#bc6ed3db2cedc1ba9c0a2183620fe2f792c3bf3f" + integrity sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw== + +"@rollup/rollup-linux-s390x-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz#440c4f6753274e2928e06d2a25613e5a1cf97b41" + integrity sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA== + +"@rollup/rollup-linux-x64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz#1e936446f90b2574ea4a83b4842a762cc0a0aed3" + integrity sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA== + +"@rollup/rollup-linux-x64-musl@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz#c6f304dfba1d5faf2be5d8b153ccbd8b5d6f1166" + integrity sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA== + +"@rollup/rollup-win32-arm64-msvc@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz#b4ad4a79219892aac112ed1c9d1356cad0566ef5" + integrity sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g== + +"@rollup/rollup-win32-ia32-msvc@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz#b1b22eb2a9568048961e4a6f540438b4a762aa62" + integrity sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ== + +"@rollup/rollup-win32-x64-msvc@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz#87079f137b5fdb75da11508419aa998cc8cc3d8b" + integrity sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg== "@shikijs/core@1.22.2": version "1.22.2" @@ -5314,6 +5314,13 @@ html-escaper@^2.0.0: resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + html-void-elements@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" @@ -5382,6 +5389,20 @@ husky@^9.0.11: resolved "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz" integrity sha512-AB6lFlbwwyIqMdHYhwPe+kjOC3Oc5P3nThEoW/AaO2BX3vJDjWPFxYLxokUZOo6RNX20He3AaT8sESs9NJcmEw== +i18next-browser-languagedetector@^8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz#c3ca311e249d2f7d8bb9b3b13ac9af380a3b15b0" + integrity sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g== + dependencies: + "@babel/runtime" "^7.23.2" + +i18next@^25.3.2: + version "25.3.2" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.3.2.tgz#3d6a7d1dc058caa1b9bdca47fd585483e2e7a637" + integrity sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA== + dependencies: + "@babel/runtime" "^7.27.6" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" @@ -7550,6 +7571,14 @@ react-dom@^19.1.0: dependencies: scheduler "^0.26.0" +react-i18next@^15.6.1: + version "15.6.1" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-15.6.1.tgz#a2747bed7768faef28fa28de32ff3811b2459c20" + integrity sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg== + dependencies: + "@babel/runtime" "^7.27.6" + html-parse-stringify "^3.0.1" + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" @@ -8988,6 +9017,11 @@ vitest@^1.6: vite-node "1.6.1" why-is-node-running "^2.2.2" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + vscode-languageserver-textdocument@^1.0.12: version "1.0.12" resolved "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz"