diff --git a/.gitignore b/.gitignore index 1ba699ad..911198e5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ # misc .DS_Store .env +.idea +.vscode npm-debug.log* yarn-debug.log* diff --git a/README.md b/README.md index 763b3fb8..2d1606c9 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,12 @@ Place configuration in `.env` file and restart/rebuild the ticker-admin ```shell TICKER_API_URL=http://localhost:8080/v1 ``` + +## Localization + +Strings are localized on the [locales](./src/i18n/locales) folder. To add more languages, please update those files: + +- [i18n.ts](./src/i18n/i18n.ts) to localize all strings +- [UserListItem.tsx](./src/components/user/UserListItem.tsx) to localize `dayjs` relative times + +To add a new string, please use the `t('stringKey')` notation and update all the locales. diff --git a/package-lock.json b/package-lock.json index e7a4e988..3ceb3e1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,18 +18,20 @@ "@fortawesome/react-fontawesome": "^3.1.1", "@mui/icons-material": "^7.3.6", "@mui/material": "^7.2.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", "@tanstack/react-query": "^5.90.16", "@tanstack/react-query-devtools": "^5.91.2", "@types/react": "^18.3.10", "dayjs": "^1.11.13", "emoji-mart": "^5.6.0", + "i18next": "^25.7.3", + "i18next-browser-languagedetector": "^8.2.0", "jwt-decode": "^4.0.0", "linkify-react": "^4.3.2", "linkifyjs": "^4.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", + "react-i18next": "^16.5.1", "react-markdown": "^9.0.1", "react-router": "^7.5.2", "react-router-dom": "^7.5.1" @@ -6034,6 +6036,15 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.0", "license": "MIT", @@ -6074,6 +6085,46 @@ "node": ">= 14" } }, + "node_modules/i18next": { + "version": "25.7.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz", + "integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.1", "dev": true, @@ -8509,6 +8560,33 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "16.5.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz", + "integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "16.13.1", "license": "MIT" @@ -10875,7 +10953,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11020,6 +11098,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -11216,6 +11303,15 @@ "vitest": ">=2.0.0" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "dev": true, diff --git a/package.json b/package.json index ce13b11f..e7f963b8 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,15 @@ "@types/react": "^18.3.10", "dayjs": "^1.11.13", "emoji-mart": "^5.6.0", + "i18next": "^25.7.3", + "i18next-browser-languagedetector": "^8.2.0", "jwt-decode": "^4.0.0", "linkify-react": "^4.3.2", "linkifyjs": "^4.3.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", + "react-i18next": "^16.5.1", "react-markdown": "^9.0.1", "react-router": "^7.5.2", "react-router-dom": "^7.5.1" diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index 7dce7b9e..d410f906 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -1,12 +1,15 @@ import { CircularProgress, Stack, Typography } from '@mui/material' import { FC } from 'react' +import { useTranslation } from 'react-i18next' const Loader: FC = () => { + const { t } = useTranslation() + return ( - Loading + {t('common.loading')} ) diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 5fb206b1..73f7cf5f 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -2,6 +2,7 @@ import { FC } from 'react' import { Navigate, RouteProps } from 'react-router' import useAuth from '../contexts/useAuth' import { Roles } from '../contexts/AuthContext' +import { useTranslation } from 'react-i18next' type Props = RouteProps & { role: Roles @@ -9,6 +10,7 @@ type Props = RouteProps & { } const ProtectedRoute: FC = ({ role, outlet }) => { + const { t } = useTranslation() const { user } = useAuth() if (!user) { @@ -19,7 +21,7 @@ const ProtectedRoute: FC = ({ role, outlet }) => { //TODO: ErrorView return ( <> -

Permission denied

+

{t("error.permissionDenied")}

) } diff --git a/src/components/common/CopyToClipboard.tsx b/src/components/common/CopyToClipboard.tsx index 2866117e..3c0b130e 100644 --- a/src/components/common/CopyToClipboard.tsx +++ b/src/components/common/CopyToClipboard.tsx @@ -3,12 +3,14 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Tooltip } from '@mui/material' import { grey } from '@mui/material/colors' import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' interface Props { text: string } const CopyToClipboard: FC = ({ text }) => { + const { t } = useTranslation() const [copied, setCopied] = useState(false) const handleClick = async () => { @@ -18,8 +20,8 @@ const CopyToClipboard: FC = ({ text }) => { } return ( - - + + ) } diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx index f93d7028..b2c0aed0 100644 --- a/src/components/common/Modal.tsx +++ b/src/components/common/Modal.tsx @@ -1,6 +1,7 @@ import { Close } from '@mui/icons-material' import { Box, Breakpoint, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Stack, SxProps } from '@mui/material' import { FC, ReactNode } from 'react' +import { useTranslation } from 'react-i18next' interface Props { children: ReactNode @@ -31,6 +32,7 @@ const Modal: FC = ({ submitting = false, title, }) => { + const { t } = useTranslation() return ( @@ -46,7 +48,7 @@ const Modal: FC = ({ {submitForm && ( {submitting && ( = ({ )} diff --git a/src/components/message/MessageForm.tsx b/src/components/message/MessageForm.tsx index 6e97f9e6..0f4e682e 100644 --- a/src/components/message/MessageForm.tsx +++ b/src/components/message/MessageForm.tsx @@ -4,6 +4,7 @@ import { Box, Button, FormGroup, Stack, TextField } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import { FC, useCallback, useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' import { handleApiCall } from '../../api/Api' import { postMessageApi } from '../../api/Message' import { Ticker } from '../../api/Ticker' @@ -26,6 +27,7 @@ interface FormValues { } const MessageForm: FC = ({ ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { formState: { isSubmitSuccessful, errors }, @@ -94,13 +96,13 @@ const MessageForm: FC = ({ ticker }) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['messages', ticker.id] }) setAttachments([]) - createNotification({ content: 'Message successfully posted', severity: 'success' }) + createNotification({ content: t("message.posted"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to post message', severity: 'error' }) + createNotification({ content: t("message.errorFailedToPost"), severity: 'error' }) }, onFailure: () => { - createNotification({ content: 'Failed to post message', severity: 'error' }) + createNotification({ content: t('message.errorFailedToPost'), severity: 'error' }) }, }) @@ -116,7 +118,7 @@ const MessageForm: FC = ({ ticker }) => { const message = watch('message') const disabled = !ticker.active || isSubmitting const color = disabled ? palette.action.disabled : palette.primary['main'] - const placeholder = ticker.active ? 'Write a message' : "You can't post messages to inactive tickers." + const placeholder = ticker.active ? t('message.writeActive') : t('writeInactive') return ( @@ -130,7 +132,7 @@ const MessageForm: FC = ({ ticker }) => { color={errors.message ? 'error' : 'primary'} error={!!errors.message} helperText={ - errors.message?.type === 'maxLength' ? 'The message is too long.' : errors.message?.type === 'required' ? 'The message is required.' : null + errors.message?.type === 'maxLength' ? t('message.errorTooLong') : errors.message?.type === 'required' ? t('message.errorRequired') : null } multiline placeholder={placeholder} @@ -141,7 +143,7 @@ const MessageForm: FC = ({ ticker }) => { diff --git a/src/components/message/MessageList.tsx b/src/components/message/MessageList.tsx index c7dd97f6..db2a2210 100644 --- a/src/components/message/MessageList.tsx +++ b/src/components/message/MessageList.tsx @@ -1,5 +1,6 @@ import { Button, CircularProgress } from '@mui/material' import { FC, useEffect } from 'react' +import { useTranslation } from 'react-i18next' import { Ticker } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useMessagesQuery from '../../queries/useMessagesQuery' @@ -12,6 +13,7 @@ interface Props { } const MessageList: FC = ({ ticker }) => { + const { t } = useTranslation() const { token } = useAuth() const { data, fetchNextPage, isFetchingNextPage, hasNextPage, status } = useMessagesQuery({ token, ticker }) @@ -44,7 +46,7 @@ const MessageList: FC = ({ ticker }) => { } if (status === 'error') { - return Unable to fetch messages from server. + return {t("message.errorUnableToFetch")} } return ( @@ -54,7 +56,7 @@ const MessageList: FC = ({ ticker }) => { ) : hasNextPage ? ( ) : null} diff --git a/src/components/message/MessageListReload.tsx b/src/components/message/MessageListReload.tsx index 874f7a73..7c59083f 100644 --- a/src/components/message/MessageListReload.tsx +++ b/src/components/message/MessageListReload.tsx @@ -3,12 +3,14 @@ import { Box, Button } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import { FC, useState } from 'react' import { Ticker } from '../../api/Ticker' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const MessageListReload: FC = ({ ticker }) => { + const { t } = useTranslation() const [loading, setLoading] = useState(false) const queryClient = useQueryClient() @@ -21,7 +23,7 @@ const MessageListReload: FC = ({ ticker }) => { return ( ) diff --git a/src/components/message/MessageModalDelete.tsx b/src/components/message/MessageModalDelete.tsx index 48af261c..86aa43ac 100644 --- a/src/components/message/MessageModalDelete.tsx +++ b/src/components/message/MessageModalDelete.tsx @@ -1,5 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' +import { useTranslation } from 'react-i18next' import { handleApiCall } from '../../api/Api' import { Message, deleteMessageApi } from '../../api/Message' import useAuth from '../../contexts/useAuth' @@ -12,6 +13,7 @@ interface Props { message: Message } const MessageModalDelete: FC = ({ message, onClose, open }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const queryClient = useQueryClient() @@ -20,21 +22,21 @@ const MessageModalDelete: FC = ({ message, onClose, open }) => { handleApiCall(deleteMessageApi(token, message), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['messages', message.ticker] }) - createNotification({ content: 'Message successfully deleted', severity: 'success' }) + createNotification({ content: t("message.deleted"), severity: 'success' }) onClose() }, onError: () => { - createNotification({ content: 'Failed to delete message', severity: 'error' }) + createNotification({ content: t("message.errorFailedToDelete"), severity: 'error' }) }, onFailure: () => { - createNotification({ content: 'Failed to delete message', severity: 'error' }) + createNotification({ content: t("message.errorFailedToDelete"), severity: 'error' }) }, }) } return ( - Are you sure to delete the message? This action cannot be undone. + {t("message.questionDelete")} ) } diff --git a/src/components/navigation/UserDropdown.tsx b/src/components/navigation/UserDropdown.tsx index 564da687..b6e3fc7c 100644 --- a/src/components/navigation/UserDropdown.tsx +++ b/src/components/navigation/UserDropdown.tsx @@ -1,11 +1,13 @@ import { AccountCircle } from '@mui/icons-material' import { IconButton, Menu, MenuItem } from '@mui/material' import React, { FC, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import UserChangePasswordModalForm from '../user/UserChangePasswordModalForm' const UserDropdown: FC = () => { + const { t } = useTranslation() const [formModalOpen, setFormModalOpen] = useState(false) const [anchorEl, setAnchorEl] = useState(null) const { user, logout } = useAuth() @@ -21,7 +23,7 @@ const UserDropdown: FC = () => { const handleLogout = useCallback(() => { logout() - createNotification({ content: 'You have been logged out' }) + createNotification({ content: t("user.loggedOut") }) }, [createNotification, logout]) return ( @@ -54,9 +56,9 @@ const UserDropdown: FC = () => { setFormModalOpen(true) }} > - Change Password + {t("user.changePassword")} - Logout + {t("user.logout")} setFormModalOpen(false)} open={formModalOpen} /> diff --git a/src/components/settings/InactiveSettingsCard.tsx b/src/components/settings/InactiveSettingsCard.tsx index d2d60be2..7a19a633 100644 --- a/src/components/settings/InactiveSettingsCard.tsx +++ b/src/components/settings/InactiveSettingsCard.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Box, Button, Card, CardContent, Divider, Grid, Typography } from '@mui/material' import { Stack } from '@mui/system' import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' import useAuth from '../../contexts/useAuth' import useInactiveSettingsQuery from '../../queries/useInactiveSettingsQuery' import ErrorView from '../../views/ErrorView' @@ -10,6 +11,7 @@ import Loader from '../Loader' import InactiveSettingsModalForm from './InactiveSettingsModalForm' const InactiveSettingsCard: FC = () => { + const { t } = useTranslation() const [formOpen, setFormOpen] = useState(false) const { token } = useAuth() const { isLoading, error, data } = useInactiveSettingsQuery({ token }) @@ -37,33 +39,33 @@ const InactiveSettingsCard: FC = () => { - Inactive Settings + {t('title.inactive')} - These settings have affect for inactive or non-configured tickers. + {t('status.description')} - Headline + {t('common.headline')} {setting.value.headline} - Subheadline + {t('common.subheadline')} {setting.value.subHeadline} - Description + {t('common.description')} {setting.value.description} @@ -71,7 +73,7 @@ const InactiveSettingsCard: FC = () => { - Author + {t('common.author')} {setting.value.author} @@ -79,7 +81,7 @@ const InactiveSettingsCard: FC = () => { - Homepage + {t('integrations.homepage')} {setting.value.homepage} @@ -87,7 +89,7 @@ const InactiveSettingsCard: FC = () => { - E-Mail + {t('user.email')} {setting.value.email} @@ -95,7 +97,7 @@ const InactiveSettingsCard: FC = () => { - Twitter + {t('social.twitter')} {setting.value.twitter} diff --git a/src/components/settings/InactiveSettingsForm.tsx b/src/components/settings/InactiveSettingsForm.tsx index 92d3e0f8..40524ec9 100644 --- a/src/components/settings/InactiveSettingsForm.tsx +++ b/src/components/settings/InactiveSettingsForm.tsx @@ -2,6 +2,7 @@ import { FormGroup, Grid, TextField } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' import { handleApiCall } from '../../api/Api' import { InactiveSetting, Setting, putInactiveSettingsApi } from '../../api/Settings' import useAuth from '../../contexts/useAuth' @@ -25,6 +26,7 @@ interface FormValues { } const InactiveSettingsForm: FC = ({ name, setting, callback, setSubmitting }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { handleSubmit, register } = useForm({ defaultValues: { @@ -64,12 +66,12 @@ const InactiveSettingsForm: FC = ({ name, setting, callback, setSubmittin - + - + @@ -80,29 +82,29 @@ const InactiveSettingsForm: FC = ({ name, setting, callback, setSubmittin multiline {...register('description')} defaultValue={setting.value.description} - label="Description" + label={t('common.description')} required /> - + - + - + - + diff --git a/src/components/settings/InactiveSettingsModalForm.tsx b/src/components/settings/InactiveSettingsModalForm.tsx index f88b4118..1dd22468 100644 --- a/src/components/settings/InactiveSettingsModalForm.tsx +++ b/src/components/settings/InactiveSettingsModalForm.tsx @@ -2,6 +2,7 @@ import { FC, useState } from 'react' import { InactiveSetting, Setting } from '../../api/Settings' import Modal from '../common/Modal' import InactiveSettingsForm from './InactiveSettingsForm' +import { useTranslation } from 'react-i18next' interface Props { open: boolean @@ -10,10 +11,11 @@ interface Props { } const InactiveSettingsModalForm: FC = ({ open, onClose, setting }) => { + const { t } = useTranslation() const [submitting, setSubmitting] = useState(false) return ( - + ) diff --git a/src/components/ticker/BlueskyCard.tsx b/src/components/ticker/BlueskyCard.tsx index cda435e5..97688ef8 100644 --- a/src/components/ticker/BlueskyCard.tsx +++ b/src/components/ticker/BlueskyCard.tsx @@ -9,12 +9,14 @@ import { Ticker, deleteTickerBlueskyApi, putTickerBlueskyApi } from '../../api/T import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import BlueskyModalForm from './BlueskyModalForm' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const BlueskyCard: FC = ({ ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const [open, setOpen] = useState(false) @@ -27,10 +29,10 @@ const BlueskyCard: FC = ({ ticker }) => { handleApiCall(deleteTickerBlueskyApi(token, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Bluesky integration successfully deleted', severity: 'success' }) + createNotification({ content: t("integrations.bluesky.deleted"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to delete Bluesky integration', severity: 'error' }) + createNotification({ content: t("integrations.bluesky.errorDelete"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -42,10 +44,10 @@ const BlueskyCard: FC = ({ ticker }) => { handleApiCall(putTickerBlueskyApi(token, { active: !bluesky.active }, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: `Bluesky integration ${bluesky.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + createNotification({ content: t(bluesky.active ? "integrations.bluesky.disabled" : "integrations.bluesky.enabled"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to update Bluesky integration', severity: 'error' }) + createNotification({ content: t("integrations.bluesky.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -67,7 +69,7 @@ const BlueskyCard: FC = ({ ticker }) => { Bluesky @@ -75,13 +77,13 @@ const BlueskyCard: FC = ({ ticker }) => { {bluesky.connected ? ( - You are connected with Bluesky. - Your Profile: {profileLink} + {t('integrations.bluesky.connected')} + {t('integrations.yourProfile')} {profileLink} ) : ( - You are not connected with Bluesky. - New messages will not be published to your account and old messages can not be deleted anymore. + {t('integrations.bluesky.notConnected')} + {t('integrations.noNewMessages', { type: t("common.account") })} )} @@ -89,15 +91,15 @@ const BlueskyCard: FC = ({ ticker }) => { {bluesky.active ? ( ) : ( )} ) : null} diff --git a/src/components/ticker/BlueskyForm.tsx b/src/components/ticker/BlueskyForm.tsx index f8d57b84..0cd18ca6 100644 --- a/src/components/ticker/BlueskyForm.tsx +++ b/src/components/ticker/BlueskyForm.tsx @@ -6,6 +6,7 @@ import { handleApiCall } from '../../api/Api' import { Ticker, TickerBlueskyFormData, putTickerBlueskyApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' +import { useTranslation } from 'react-i18next' interface Props { callback: () => void @@ -13,6 +14,7 @@ interface Props { } const BlueskyForm: FC = ({ callback, ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const bluesky = ticker.bluesky const { token } = useAuth() @@ -34,12 +36,12 @@ const BlueskyForm: FC = ({ callback, ticker }) => { handleApiCall(putTickerBlueskyApi(token, data, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Bluesky integration was successfully updated', severity: 'success' }) + createNotification({ content: t("integrations.bluesky.updated"), severity: 'success' }) callback() }, onError: () => { - setError('root.authenticationFailed', { message: 'Authentication failed' }) - createNotification({ content: 'Failed to update Bluesky integration', severity: 'error' }) + setError('root.authenticationFailed', { message: t("error.authentication") }) + createNotification({ content: t("integrations.bluesky.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -51,7 +53,7 @@ const BlueskyForm: FC = ({ callback, ticker }) => {
- You need to create a application password in Bluesky. + {t('integrations.bluesky.createAppPassword')} {errors.root?.authenticationFailed && ( @@ -60,12 +62,12 @@ const BlueskyForm: FC = ({ callback, ticker }) => { )} - } label="Active" /> + } label={t('status.active')} /> - + diff --git a/src/components/ticker/BlueskyModalForm.tsx b/src/components/ticker/BlueskyModalForm.tsx index 8142774e..0c718e16 100644 --- a/src/components/ticker/BlueskyModalForm.tsx +++ b/src/components/ticker/BlueskyModalForm.tsx @@ -2,6 +2,7 @@ import { FC } from 'react' import { Ticker } from '../../api/Ticker' import Modal from '../common/Modal' import BlueskyForm from './BlueskyForm' +import { useTranslation } from 'react-i18next' interface Props { onClose: () => void @@ -10,8 +11,10 @@ interface Props { } const BlueskyModalForm: FC = ({ onClose, open, ticker }) => { + const { t } = useTranslation() + return ( - + ) diff --git a/src/components/ticker/LocationSearch.tsx b/src/components/ticker/LocationSearch.tsx index a6abeb3c..7ff52fa2 100644 --- a/src/components/ticker/LocationSearch.tsx +++ b/src/components/ticker/LocationSearch.tsx @@ -1,5 +1,6 @@ import { Autocomplete, TextField } from '@mui/material' import React, { FC, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' interface SearchResult { place_id: number @@ -25,6 +26,7 @@ interface Props { } const LocationSearch: FC = ({ callback }) => { + const { t } = useTranslation() const [options, setOptions] = useState([]) const previousController = useRef() @@ -57,7 +59,7 @@ const LocationSearch: FC = ({ callback }) => { onChange={handleChange} onInputChange={handleInputChange} options={options} - renderInput={params => } + renderInput={params => } /> ) } diff --git a/src/components/ticker/MastodonCard.tsx b/src/components/ticker/MastodonCard.tsx index e4548d71..c57c3c1c 100644 --- a/src/components/ticker/MastodonCard.tsx +++ b/src/components/ticker/MastodonCard.tsx @@ -9,12 +9,14 @@ import { Ticker, deleteTickerMastodonApi, putTickerMastodonApi } from '../../api import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import MastodonModalForm from './MastodonModalForm' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const MastodonCard: FC = ({ ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const [open, setOpen] = useState(false) @@ -27,10 +29,10 @@ const MastodonCard: FC = ({ ticker }) => { handleApiCall(deleteTickerMastodonApi(token, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Mastodon integration successfully deleted', severity: 'success' }) + createNotification({ content: t("integrations.mastodon.deleted"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to delete Mastodon integration', severity: 'error' }) + createNotification({ content: t("integrations.mastodon.errorDelete"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -42,10 +44,10 @@ const MastodonCard: FC = ({ ticker }) => { handleApiCall(putTickerMastodonApi(token, { active: !mastodon.active }, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: `Mastodon integration ${mastodon.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + createNotification({ content: t(mastodon.active ? "integrations.mastodon.disabled" : "integrations.mastodon.enabled"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to update Mastodon integration', severity: 'error' }) + createNotification({ content: t("integrations.mastodon.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -67,7 +69,7 @@ const MastodonCard: FC = ({ ticker }) => { Mastodon @@ -75,16 +77,16 @@ const MastodonCard: FC = ({ ticker }) => { {mastodon.connected ? ( - You are connected with Mastodon. - Your Profile: {profileLink} + {t('integrations.mastodon.connected')} + {t('integrations.yourProfile')} {profileLink} ) : ( - You are not connected with Mastodon. + {t('integrations.mastodon.notConnected')} - New messages will not be published to your account and old messages can not be deleted anymore. + {t('integrations.noNewMessages', { type: t('common.account') })} )} @@ -93,15 +95,15 @@ const MastodonCard: FC = ({ ticker }) => { {mastodon.active ? ( ) : ( )} ) : null} diff --git a/src/components/ticker/MastodonForm.tsx b/src/components/ticker/MastodonForm.tsx index d128391f..7766a6b2 100644 --- a/src/components/ticker/MastodonForm.tsx +++ b/src/components/ticker/MastodonForm.tsx @@ -6,6 +6,7 @@ import { handleApiCall } from '../../api/Api' import { Ticker, TickerMastodonFormData, putTickerMastodonApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' +import { useTranslation } from 'react-i18next' interface Props { callback: () => void @@ -13,6 +14,7 @@ interface Props { } const MastodonForm: FC = ({ callback, ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const mastodon = ticker.mastodon const { token } = useAuth() @@ -28,11 +30,11 @@ const MastodonForm: FC = ({ callback, ticker }) => { handleApiCall(putTickerMastodonApi(token, data, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Mastodon integration was successfully updated', severity: 'success' }) + createNotification({ content: t("integrations.mastodon.updated"), severity: 'success' }) callback() }, onError: () => { - createNotification({ content: 'Failed to update Mastodon integration', severity: 'error' }) + createNotification({ content: t("integrations.mastodon.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -44,37 +46,40 @@ const MastodonForm: FC = ({ callback, ticker }) => { - - You need to create a Application for Ticker in Mastodon. Go to your profile settings in Mastodon. You find a menu point "Developer" where you need - to create an Application. After saving you see the required secrets and tokens. - + {t('integrations.mastodon.createApplication')} - Required Scopes: read write write:media write:statuses + {t('application.requiredScopes')} read write write:media write:statuses - } label="Active" /> + } label={t('status.active')} /> - + - + - + - + diff --git a/src/components/ticker/MastodonModalForm.tsx b/src/components/ticker/MastodonModalForm.tsx index 79856b2a..06b54ac4 100644 --- a/src/components/ticker/MastodonModalForm.tsx +++ b/src/components/ticker/MastodonModalForm.tsx @@ -2,6 +2,7 @@ import { FC } from 'react' import { Ticker } from '../../api/Ticker' import MastodonForm from './MastodonForm' import Modal from '../common/Modal' +import { useTranslation } from 'react-i18next' interface Props { onClose: () => void @@ -10,8 +11,10 @@ interface Props { } const MastodonModalForm: FC = ({ onClose, open, ticker }) => { + const { t } = useTranslation() + return ( - + ) diff --git a/src/components/ticker/SignalGroupAdminForm.tsx b/src/components/ticker/SignalGroupAdminForm.tsx index f7406750..78472393 100644 --- a/src/components/ticker/SignalGroupAdminForm.tsx +++ b/src/components/ticker/SignalGroupAdminForm.tsx @@ -7,6 +7,7 @@ import { handleApiCall } from '../../api/Api' import { Ticker, TickerSignalGroupAdminFormData, putTickerSignalGroupAdminApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' +import { useTranslation } from 'react-i18next' interface Props { callback: () => void @@ -15,6 +16,7 @@ interface Props { } const SignalGroupAdminForm: FC = ({ callback, ticker, setSubmitting }) => { + const { t } = useTranslation() const { token } = useAuth() const { formState: { errors }, @@ -29,12 +31,12 @@ const SignalGroupAdminForm: FC = ({ callback, ticker, setSubmitting }) => handleApiCall(putTickerSignalGroupAdminApi(token, data, ticker), { onSuccess: () => { - createNotification({ content: 'Number successfully added to Signal group', severity: 'success' }) + createNotification({ content: t("integrations.signal.numberAdded"), severity: 'success' }) callback() }, onError: () => { - createNotification({ content: 'Failed to add number to Signal group', severity: 'error' }) - setError('number', { message: 'Failed to add number to Signal group' }) + createNotification({ content: t("integrations.signal.errorAddNumber"), severity: 'error' }) + setError('number', { message: t('integrations.signal.errorAddNumber') }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -48,13 +50,13 @@ const SignalGroupAdminForm: FC = ({ callback, ticker, setSubmitting }) => - Only do this if extra members with write access are needed. + {t('integrations.signal.onlyIfNeeded')} void @@ -10,10 +11,11 @@ interface Props { } const SignalGroupAdminModalForm: FC = ({ onClose, open, ticker }) => { + const { t } = useTranslation() const [submitting, setSubmitting] = useState(false) return ( - + ) diff --git a/src/components/ticker/SignalGroupCard.tsx b/src/components/ticker/SignalGroupCard.tsx index 3d059083..8982025f 100644 --- a/src/components/ticker/SignalGroupCard.tsx +++ b/src/components/ticker/SignalGroupCard.tsx @@ -25,12 +25,14 @@ import { Ticker, deleteTickerSignalGroupApi, putTickerSignalGroupApi } from '../ import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import SignalGroupAdminModalForm from './SignalGroupAdminModalForm' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const SignalGroupCard: FC = ({ ticker }) => { + const { t } = useTranslation() const { token } = useAuth() const [dialogDeleteOpen, setDialogDeleteOpen] = useState(false) const [adminOpen, setAdminOpen] = useState(false) @@ -49,11 +51,11 @@ const SignalGroupCard: FC = ({ ticker }) => { handleApiCall(putTickerSignalGroupApi(token, { active: true }, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Signal Group enabled successfully', severity: 'success' }) + createNotification({ content: t("integrations.signal.enabled"), severity: 'success' }) setSubmittingAdd(false) }, onError: () => { - createNotification({ content: 'Failed to configure Signal group', severity: 'error' }) + createNotification({ content: t("integrations.signal.errorConfigure"), severity: 'error' }) setSubmittingAdd(false) }, onFailure: error => { @@ -69,10 +71,10 @@ const SignalGroupCard: FC = ({ ticker }) => { handleApiCall(putTickerSignalGroupApi(token, { active: !signalGroup.active }, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: `Signal group ${signalGroup.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + createNotification({ content: t(signalGroup.active ? "integrations.signal.disabled" : "integrations.signal.enabled"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to update Signal group', severity: 'error' }) + createNotification({ content: t("integrations.signal.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -87,10 +89,10 @@ const SignalGroupCard: FC = ({ ticker }) => { deleteTickerSignalGroupApi(token, ticker) .finally(() => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Signal Group deleted successfully', severity: 'success' }) + createNotification({ content: t("integrations.signal.deleted"), severity: 'success' }) }) .catch(() => { - createNotification({ content: 'Failed to delete Signal group', severity: 'error' }) + createNotification({ content: ("integrations.signal.errorDelete"), severity: 'error' }) }) .finally(() => { setDialogDeleteOpen(false) @@ -109,12 +111,12 @@ const SignalGroupCard: FC = ({ ticker }) => { - Signal Group + {t('integrations.signal.title')} {signalGroup.connected ? null : ( {submittingAdd && ( = ({ ticker }) => { {signalGroup.connected ? ( - You have a Signal group connected. - Your Signal group invite link: {groupLink} + {t('integrations.signal.connected')} + {t('integrations.signal.inviteLink', { link: groupLink })} ) : ( - You don't have a Signal group connected. - New messages will not be published to a group and old messages can not be deleted. + {t('integrations.signal.notConnected')} + {t('integrations.noNewMessages', { type: t("common.group") })} )} {signalGroup.connected ? ( {signalGroup.active ? ( ) : ( )} {submittingToggle && ( @@ -177,21 +179,21 @@ const SignalGroupCard: FC = ({ ticker }) => { )} ) : null} setAdminOpen(false)} ticker={ticker} /> - Delete Signal Group + {t("integrations.signal.delete")} - Are you sure you want to delete the Signal group? This is irreversible. + {t('integrations.signal.questionDelete')} - + {submittingDelete && ( = ({ ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const [open, setOpen] = useState(false) @@ -26,10 +28,10 @@ const TelegramCard: FC = ({ ticker }) => { handleApiCall(putTickerTelegramApi(token, { active: !telegram.active }, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: `Telegram integration ${telegram.active ? 'disabled' : 'enabled'} successfully`, severity: 'success' }) + createNotification({ content: t(telegram.active ? "integrations.telegram.disabled" : "integrations.telegram.enabled"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to update Telegram integration', severity: 'error' }) + createNotification({ content: t("integrations.telegram.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -41,10 +43,10 @@ const TelegramCard: FC = ({ ticker }) => { handleApiCall(deleteTickerTelegramApi(token, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Telegram integration successfully deleted', severity: 'success' }) + createNotification({ content: t("integrations.telegram.deleted"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to delete Telegram integration', severity: 'error' }) + createNotification({ content: t("integrations.telegram.errorDelete"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -63,10 +65,10 @@ const TelegramCard: FC = ({ ticker }) => { - Telegram + {t('integrations.telegram.title')} @@ -74,15 +76,13 @@ const TelegramCard: FC = ({ ticker }) => { {telegram.connected ? ( - You are connected with Telegram. - - Your Channel: {channelLink} (Bot: {telegram.botUsername}) - + {t('integrations.telegram.connected')} + {t('integrations.telegram.yourChannel')} {channelLink} {t("integrations.telegram.bot", {bot: telegram.botUsername})} ) : ( - You are not connected with Telegram. - New messages will not be published to your channel and old messages can not be deleted anymore. + {t('integrations.telegram.notConnected')} + {t('integrations.noNewMessages', { type: t('common.channel') })} )} @@ -90,15 +90,15 @@ const TelegramCard: FC = ({ ticker }) => { {telegram.active ? ( ) : ( )} ) : null} diff --git a/src/components/ticker/TelegramForm.tsx b/src/components/ticker/TelegramForm.tsx index f3048de6..f0dad4fe 100644 --- a/src/components/ticker/TelegramForm.tsx +++ b/src/components/ticker/TelegramForm.tsx @@ -6,6 +6,7 @@ import { handleApiCall } from '../../api/Api' import { Ticker, putTickerTelegramApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' +import { useTranslation } from 'react-i18next' interface Props { callback: () => void @@ -18,6 +19,7 @@ interface FormValues { } const TelegramForm: FC = ({ callback, ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const telegram = ticker.telegram const { token } = useAuth() @@ -33,11 +35,11 @@ const TelegramForm: FC = ({ callback, ticker }) => { handleApiCall(putTickerTelegramApi(token, data, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Telegram integration was successfully updated', severity: 'success' }) + createNotification({ content: t("integrations.telegram.updated"), severity: 'success' }) callback() }, onError: () => { - createNotification({ content: 'Failed to update Telegram integration', severity: 'error' }) + createNotification({ content: t("integrations.telegram.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -49,11 +51,11 @@ const TelegramForm: FC = ({ callback, ticker }) => { - Only public Telegram Channels are supported. The name of the Channel is prefixed with an @ (e.g. @channel). + {t('integrations.telegram.onlyPublic')} - } label="Active" /> + } label={t("status.active")} /> @@ -62,11 +64,11 @@ const TelegramForm: FC = ({ callback, ticker }) => { {...register('channelName', { pattern: { value: /@\w+/i, - message: 'The Channel must start with an @', + message: t("integrations.telegram.errorNaming"), }, })} defaultValue={telegram.channelName} - label="Channel" + label={t('integrations.telegram.channel')} required /> diff --git a/src/components/ticker/TelegramModalForm.tsx b/src/components/ticker/TelegramModalForm.tsx index 45a3d48c..5677e536 100644 --- a/src/components/ticker/TelegramModalForm.tsx +++ b/src/components/ticker/TelegramModalForm.tsx @@ -2,6 +2,7 @@ import { FC } from 'react' import { Ticker } from '../../api/Ticker' import TelegramForm from './TelegramForm' import Modal from '../common/Modal' +import { useTranslation } from 'react-i18next' interface Props { onClose: () => void @@ -10,8 +11,10 @@ interface Props { } const TelegramModalForm: FC = ({ onClose, open, ticker }) => { + const { t } = useTranslation() + return ( - + ) diff --git a/src/components/ticker/Ticker.tsx b/src/components/ticker/Ticker.tsx index 616e2d28..9841fb5d 100644 --- a/src/components/ticker/Ticker.tsx +++ b/src/components/ticker/Ticker.tsx @@ -12,6 +12,7 @@ import TickerCard from './TickerCard' import TickerDangerZoneCard from './TickerDangerZoneCard' import TickerModalForm from './TickerModalForm' import TickerUsersCard from './TickerUsersCard' +import { useTranslation } from 'react-i18next' interface Props { ticker?: Model @@ -19,13 +20,14 @@ interface Props { } const Ticker: FC = ({ ticker, isLoading }) => { + const { t } = useTranslation() const { user } = useAuth() const [formModalOpen, setFormModalOpen] = useState(false) const headline = () => ( - Ticker + {t('title.ticker')} { @@ -62,7 +64,7 @@ const Ticker: FC = ({ ticker, isLoading }) => { {headline()} {!ticker.active ? ( - This ticker is currently disabled. + {t("tickers.disabled")} ) : null} diff --git a/src/components/ticker/TickerCard.tsx b/src/components/ticker/TickerCard.tsx index 61ce0e49..dc92fb56 100644 --- a/src/components/ticker/TickerCard.tsx +++ b/src/components/ticker/TickerCard.tsx @@ -4,6 +4,7 @@ import { Campaign, Check, Pause } from '@mui/icons-material' import { Card, CardContent, Chip, Divider, Link, Stack, Tooltip, Typography } from '@mui/material' import { grey } from '@mui/material/colors' import { FC, ReactNode } from 'react' +import { useTranslation } from 'react-i18next' import { Ticker } from '../../api/Ticker' import CopyToClipboard from '../common/CopyToClipboard' @@ -12,8 +13,9 @@ interface Props { } const TickerCard: FC = ({ ticker }) => { + const { t } = useTranslation() const icon = ticker.active ? : - const status = `Status: ${ticker.active ? 'Active' : 'Inactive'}` + const status = t(ticker.active ? "status.showActive" : "status.showInactive") const color = ticker.active ? 'primary' : 'warning' const hasIntegrations = @@ -25,11 +27,11 @@ const TickerCard: FC = ({ ticker }) => { - Info + {t('common.info')} - Title + {t('title.title')} {ticker.title} @@ -37,16 +39,18 @@ const TickerCard: FC = ({ ticker }) => { - Integrations + {t('title.integrations')} - {hasIntegrations ? : } + {hasIntegrations ? : } ) } const Integrations = ({ ticker }: { ticker: Ticker }) => { + const { t } = useTranslation() + return ( {ticker.websites.length > 0 && ( @@ -67,7 +71,7 @@ const Integrations = ({ ticker }: { ticker: Ticker }) => { )} {ticker.mastodon.connected && ( { )} {ticker.telegram.connected && ( { )} {ticker.bluesky.connected && ( } /> )} {ticker.signalGroup.connected && ( } + label={t('integrations.signal.title')} + value={} /> )} @@ -115,9 +119,11 @@ const TickerProperty = ({ label, value }: { label: string; value: ReactNode }) = } const IntegrationChip = ({ active, title, link }: { active: boolean; title: string; link: string }) => { + const { t } = useTranslation() + return ( - + diff --git a/src/components/ticker/TickerDangerZoneCard.tsx b/src/components/ticker/TickerDangerZoneCard.tsx index 1dfc777d..e2775510 100644 --- a/src/components/ticker/TickerDangerZoneCard.tsx +++ b/src/components/ticker/TickerDangerZoneCard.tsx @@ -4,22 +4,24 @@ import { Ticker } from '../../api/Ticker' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faBiohazard, faTrash } from '@fortawesome/free-solid-svg-icons' import TickerResetModal from './TickerResetModal' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const TickerDangerZoneCard: FC = ({ ticker }) => { + const { t } = useTranslation() const [resetOpen, setResetOpen] = useState(false) return ( - Danger Zone + {t('common.dangerZone')} setResetOpen(false)} open={resetOpen} ticker={ticker} /> diff --git a/src/components/ticker/TickerList.tsx b/src/components/ticker/TickerList.tsx index 09497372..b164efaa 100644 --- a/src/components/ticker/TickerList.tsx +++ b/src/components/ticker/TickerList.tsx @@ -5,12 +5,14 @@ import { GetTickersQueryParams } from '../../api/Ticker' import useDebounce from '../../hooks/useDebounce' import TickerListFilter from './TickerListFilter' import TickerListItems from './TickerListItems' +import { useTranslation } from 'react-i18next' interface Props { token: string } const TickerList: FC = ({ token }) => { + const { t } = useTranslation() const [params, setParams] = useState({}) const debouncedValue = useDebounce(params, 200, {}) const [, setSearchParams] = useSearchParams() @@ -70,20 +72,20 @@ const TickerList: FC = ({ token }) => { handleSortChange('id')}> - ID + {t("common.ID")} handleSortChange('active')}> - Active + {t("status.active")} handleSortChange('title')}> - Title + {t("title.title")} - Web Origins + {t("common.webOrigins")} diff --git a/src/components/ticker/TickerListFilter.tsx b/src/components/ticker/TickerListFilter.tsx index 6581ef27..d677acb9 100644 --- a/src/components/ticker/TickerListFilter.tsx +++ b/src/components/ticker/TickerListFilter.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Box, Stack, TextField, ToggleButton, ToggleButtonGroup } from '@mui/material' import { FC } from 'react' import { GetTickersQueryParams } from '../../api/Ticker' +import { useTranslation } from 'react-i18next' interface Props { params: GetTickersQueryParams @@ -12,6 +13,8 @@ interface Props { } const TickerListFilter: FC = ({ params, onTitleChange, onOriginChange, onActiveChange }) => { + const { t } = useTranslation() + return ( @@ -19,27 +22,33 @@ const TickerListFilter: FC = ({ params, onTitleChange, onOriginChange, on onTitleChange('title', e.target.value)} - placeholder="Filter by title" + placeholder={t('filter.byTitle')} size="small" value={params.title} variant="outlined" /> - onOriginChange('origin', e.target.value)} placeholder="Filter by origin" size="small" value={params.origin} /> + onOriginChange('origin', e.target.value)} + placeholder={t('filter.byOrigin')} + size="small" + value={params.origin} + /> - All + {t('filter.all')} - Active + {t('status.active')} - Inactive + {t('status.inactive')} diff --git a/src/components/ticker/TickerListItem.tsx b/src/components/ticker/TickerListItem.tsx index 59957a3d..31bb8c5a 100644 --- a/src/components/ticker/TickerListItem.tsx +++ b/src/components/ticker/TickerListItem.tsx @@ -8,12 +8,14 @@ import { Ticker } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import TickerModalDelete from './TickerModalDelete' import TickerModalForm from './TickerModalForm' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const TickerListItem: FC = ({ ticker }: Props) => { + const { t } = useTranslation() const { user } = useAuth() const navigate = useNavigate() const [formModalOpen, setFormModalOpen] = useState(false) @@ -32,7 +34,7 @@ const TickerListItem: FC = ({ ticker }: Props) => { navigate(`/ticker/${ticker.id}`) } - const origins = ticker.websites.length > 0 ? ticker.websites.map(website => website.origin).join(', ') : 'No origins' + const origins = ticker.websites.length > 0 ? ticker.websites.map(website => website.origin).join(', ') : t("integrations.website.noOrigins") return ( @@ -72,7 +74,7 @@ const TickerListItem: FC = ({ ticker }: Props) => { }} > - Use + {t('action.use')} { @@ -81,7 +83,7 @@ const TickerListItem: FC = ({ ticker }: Props) => { }} > - Edit + {t("action.edit")} {user?.roles.includes('admin') ? ( <> @@ -93,7 +95,7 @@ const TickerListItem: FC = ({ ticker }: Props) => { sx={{ color: colors.red[400] }} > - Delete + {t('action.delete')} ) : null} diff --git a/src/components/ticker/TickerListItems.tsx b/src/components/ticker/TickerListItems.tsx index a0938be0..5893db1d 100644 --- a/src/components/ticker/TickerListItems.tsx +++ b/src/components/ticker/TickerListItems.tsx @@ -3,6 +3,7 @@ import { FC } from 'react' import { GetTickersQueryParams } from '../../api/Ticker' import useTickersQuery from '../../queries/useTickersQuery' import TickerListItem from './TickerListItem' +import { useTranslation } from 'react-i18next' interface Props { token: string @@ -10,6 +11,7 @@ interface Props { } const TickerListItems: FC = ({ token, params }) => { + const { t } = useTranslation() const { data, isLoading, error } = useTickersQuery({ token, params: params }) const tickers = data?.data?.tickers || [] @@ -21,7 +23,7 @@ const TickerListItems: FC = ({ token, params }) => { - Loading + {t('common.loading')} @@ -35,7 +37,7 @@ const TickerListItems: FC = ({ token, params }) => { - Unable to fetch tickers from server. + {t("tickers.errorUnableToFetch")} @@ -48,7 +50,7 @@ const TickerListItems: FC = ({ token, params }) => { - No tickers found. + {t("tickers.error0Found")} diff --git a/src/components/ticker/TickerModalDelete.tsx b/src/components/ticker/TickerModalDelete.tsx index cde26a5f..f60773f7 100644 --- a/src/components/ticker/TickerModalDelete.tsx +++ b/src/components/ticker/TickerModalDelete.tsx @@ -5,6 +5,7 @@ import { Ticker, deleteTickerApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import Modal from '../common/Modal' +import { useTranslation } from 'react-i18next' interface Props { onClose: () => void @@ -13,6 +14,7 @@ interface Props { } const TickerModalDelete: FC = ({ open, onClose, ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const queryClient = useQueryClient() @@ -21,20 +23,20 @@ const TickerModalDelete: FC = ({ open, onClose, ticker }) => { handleApiCall(deleteTickerApi(token, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickers'] }) - createNotification({ content: 'Ticker was successfully deleted', severity: 'success' }) + createNotification({ content: t("tickers.deleted"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to delete ticker', severity: 'error' }) + createNotification({ content: t('tickers.errorDelete'), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) }, }) - }, [token, ticker, queryClient, createNotification]) + }, [token, ticker, queryClient, createNotification, t]) return ( - - Are you sure to delete the ticker? This action cannot be undone. + + {t('tickers.questionDelete')} ) } diff --git a/src/components/ticker/TickerModalForm.tsx b/src/components/ticker/TickerModalForm.tsx index 3329c403..82bd8e23 100644 --- a/src/components/ticker/TickerModalForm.tsx +++ b/src/components/ticker/TickerModalForm.tsx @@ -2,6 +2,7 @@ import Campaign from '@mui/icons-material/Campaign' import Tune from '@mui/icons-material/Tune' import { Tab, Tabs } from '@mui/material' import React, { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Ticker } from '../../api/Ticker' import Modal from '../common/Modal' import TabPanel from '../common/TabPanel' @@ -15,6 +16,7 @@ interface Props { } const TickerModalForm: FC = ({ onClose, open, ticker }) => { + const { t } = useTranslation() const [submitting, setSubmitting] = useState(false) const [tabValue, setTabValue] = useState(0) @@ -28,12 +30,12 @@ const TickerModalForm: FC = ({ onClose, open, ticker }) => { onClose={onClose} open={open} submitForm={tabValue === 0 ? 'tickerForm' : undefined} - title={ticker ? 'Configure Ticker' : 'Create Ticker'} + title={t(ticker ? "tickers.configure" : "tickers.create")} submitting={submitting} > - } iconPosition="start" label="General" /> - } iconPosition="start" label="Integrations" /> + } iconPosition="start" label={t('common.general')} /> + } iconPosition="start" label={t('title.integrations')} /> diff --git a/src/components/ticker/TickerResetModal.tsx b/src/components/ticker/TickerResetModal.tsx index 0d37aefb..2e97e0a2 100644 --- a/src/components/ticker/TickerResetModal.tsx +++ b/src/components/ticker/TickerResetModal.tsx @@ -5,6 +5,7 @@ import { Ticker, putTickerResetApi } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import Modal from '../common/Modal' +import { useTranslation } from 'react-i18next' interface Props { onClose: () => void @@ -13,6 +14,7 @@ interface Props { } const TickerResetModal: FC = ({ onClose, open, ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const queryClient = useQueryClient() @@ -23,11 +25,11 @@ const TickerResetModal: FC = ({ onClose, open, ticker }) => { queryClient.invalidateQueries({ queryKey: ['messages', ticker.id] }) queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Ticker has been successfully reset', severity: 'success' }) + createNotification({ content: t("tickers.reseted"), severity: 'success' }) onClose() }, onError: () => { - createNotification({ content: 'Failed to reset ticker', severity: 'error' }) + createNotification({ content: t("tickers.errorReset"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -36,11 +38,11 @@ const TickerResetModal: FC = ({ onClose, open, ticker }) => { }, [token, ticker, queryClient, createNotification, onClose]) return ( - +

- Are you sure you want to reset the ticker? + {t("tickers.questionReset")}

-

This will remove all messages, descriptions and disable the ticker.

+

{t("tickers.resetMessage")}

) } diff --git a/src/components/ticker/TickerUserList.tsx b/src/components/ticker/TickerUserList.tsx index 43ea0626..c645b912 100644 --- a/src/components/ticker/TickerUserList.tsx +++ b/src/components/ticker/TickerUserList.tsx @@ -3,6 +3,7 @@ import { FC } from 'react' import { Ticker } from '../../api/Ticker' import { User } from '../../api/User' import TickerUsersListItem from './TickerUserListItem' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker @@ -10,8 +11,10 @@ interface Props { } const TickerUserList: FC = ({ ticker, users }) => { + const { t } = useTranslation() + if (users.length === 0) { - return There are no users granted access this ticker. + return {t("user.errorAccess")} } return ( diff --git a/src/components/ticker/TickerUserModalDelete.tsx b/src/components/ticker/TickerUserModalDelete.tsx index e8dbcac8..a14e3ab3 100644 --- a/src/components/ticker/TickerUserModalDelete.tsx +++ b/src/components/ticker/TickerUserModalDelete.tsx @@ -6,6 +6,7 @@ import { User } from '../../api/User' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import Modal from '../common/Modal' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker @@ -15,6 +16,7 @@ interface Props { } const TickerUserModalDelete: FC = ({ open, onClose, ticker, user }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const queryClient = useQueryClient() @@ -23,11 +25,11 @@ const TickerUserModalDelete: FC = ({ open, onClose, ticker, user }) => { handleApiCall(deleteTickerUserApi(token, ticker, user), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) - createNotification({ content: 'User is successfully deleted from ticker', severity: 'success' }) + createNotification({ content: t("user.deleted"), severity: 'success' }) onClose() }, onError: () => { - createNotification({ content: 'Failed to delete user from ticker', severity: 'error' }) + createNotification({ content: t("user.errorDelete"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -37,7 +39,7 @@ const TickerUserModalDelete: FC = ({ open, onClose, ticker, user }) => { return ( - Are you sure to remove {user.email} from this ticker? + {t("user.questionDelete", {user: user.email})} ) } diff --git a/src/components/ticker/TickerUsersCard.tsx b/src/components/ticker/TickerUsersCard.tsx index 238aca3a..0d1ed60e 100644 --- a/src/components/ticker/TickerUsersCard.tsx +++ b/src/components/ticker/TickerUsersCard.tsx @@ -9,12 +9,14 @@ import ErrorView from '../../views/ErrorView' import Loader from '../Loader' import TickerUserList from './TickerUserList' import TickerUsersModal from './TickerUsersModal' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const TickerUsersCard: FC = ({ ticker }) => { + const { t } = useTranslation() const { token } = useAuth() const [formOpen, setFormOpen] = useState(false) const { isLoading, error, data } = useTickerUsersQuery({ ticker, token }) @@ -24,7 +26,7 @@ const TickerUsersCard: FC = ({ ticker }) => { } if (error || data === undefined || data.data === undefined || data.status === 'error') { - return Unable to fetch users from server. + return {t("user.errorUnableToFetch")} } const users = data.data.users @@ -35,10 +37,10 @@ const TickerUsersCard: FC = ({ ticker }) => { Users - List of all granted users to this ticker. Only Admins can manage this list. + {t("user.list")} setFormOpen(false)} open={formOpen} ticker={ticker} users={users} />
diff --git a/src/components/ticker/TickerUsersForm.tsx b/src/components/ticker/TickerUsersForm.tsx index 98feeda3..3ae74f18 100644 --- a/src/components/ticker/TickerUsersForm.tsx +++ b/src/components/ticker/TickerUsersForm.tsx @@ -2,6 +2,7 @@ import { Box, Chip, FormControl, InputLabel, MenuItem, OutlinedInput, Select, Se import { useQueryClient } from '@tanstack/react-query' import { FC, useEffect, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' import { handleApiCall } from '../../api/Api' import { Ticker, putTickerUsersApi } from '../../api/Ticker' import { User, fetchUsersApi } from '../../api/User' @@ -19,6 +20,7 @@ interface FormValues { } const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const [users, setUsers] = useState>(defaultValue) const [options, setOptions] = useState>([]) @@ -41,11 +43,11 @@ const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { handleApiCall(putTickerUsersApi(token, ticker, users), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickerUsers', ticker.id] }) - createNotification({ content: 'Users were successfully updated', severity: 'success' }) + createNotification({ content: t("user.updatedMultiple"), severity: 'success' }) onSubmit() }, onError: () => { - createNotification({ content: 'Failed to update users', severity: 'error' }) + createNotification({ content: t("user.errorUpdateMultiple"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -68,7 +70,7 @@ const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { ) }, onError: () => { - createNotification({ content: 'Failed to fetch users', severity: 'error' }) + createNotification({ content: t("user.errorFetch"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -115,11 +117,11 @@ const TickerUsersForm: FC = ({ onSubmit, ticker, defaultValue }) => { return ( - Users + {t('title.users')} } - label="Tickers" + input={} + label={t("title.tickers")} multiple name={name} onChange={handleChange} diff --git a/src/components/ticker/WebsiteCard.tsx b/src/components/ticker/WebsiteCard.tsx index d55f0e83..53b3ee9b 100644 --- a/src/components/ticker/WebsiteCard.tsx +++ b/src/components/ticker/WebsiteCard.tsx @@ -8,12 +8,14 @@ import { deleteTickerWebsitesApi, Ticker } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' import WebsiteModalForm from './WebsiteModalForm' +import { useTranslation } from 'react-i18next' interface Props { ticker: Ticker } const WebsiteCard: FC = ({ ticker }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const [open, setOpen] = useState(false) @@ -26,10 +28,10 @@ const WebsiteCard: FC = ({ ticker }) => { handleApiCall(deleteTickerWebsitesApi(token, ticker), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Websites integration successfully deleted', severity: 'success' }) + createNotification({ content: t("integrations.website.deleted"), severity: 'success' }) }, onError: () => { - createNotification({ content: 'Failed to delete Websites integration', severity: 'error' }) + createNotification({ content: "integrations.website.errorDelete", severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -54,7 +56,7 @@ const WebsiteCard: FC = ({ ticker }) => { Websites
@@ -63,16 +65,16 @@ const WebsiteCard: FC = ({ ticker }) => { {websites.length > 0 ? ( - You have allowed the following websites to access your ticker: {links} + {t("integrations.website.allowed", {links: links})} ) : ( - No website origins configured. + {t("integrations.website.noOriginsConfigured")} - Without configured website origins, the ticker is not reachable from any website. + {t("integrations.website.noOriginsMessage")} )} @@ -80,7 +82,7 @@ const WebsiteCard: FC = ({ ticker }) => { {websites.length > 0 ? ( ) : null} diff --git a/src/components/ticker/WebsiteForm.tsx b/src/components/ticker/WebsiteForm.tsx index ad8574a6..a74fac3f 100644 --- a/src/components/ticker/WebsiteForm.tsx +++ b/src/components/ticker/WebsiteForm.tsx @@ -7,6 +7,7 @@ import { handleApiCall } from '../../api/Api' import { putTickerWebsitesApi, Ticker, TickerWebsite } from '../../api/Ticker' import useAuth from '../../contexts/useAuth' import useNotification from '../../contexts/useNotification' +import { useTranslation } from 'react-i18next' interface Props { callback: () => void @@ -18,6 +19,7 @@ interface FormData { } const WebsiteForm: FC = ({ callback, ticker }) => { + const { t } = useTranslation() const websites = ticker.websites const { createNotification } = useNotification() const { token } = useAuth() @@ -40,11 +42,11 @@ const WebsiteForm: FC = ({ callback, ticker }) => { handleApiCall(putTickerWebsitesApi(token, ticker, data.websites), { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['ticker', ticker.id] }) - createNotification({ content: 'Websites were successfully updated', severity: 'success' }) + createNotification({ content: t("integrations.website.updated"), severity: 'success' }) callback() }, onError: () => { - createNotification({ content: 'Failed to update websites', severity: 'error' }) + createNotification({ content: t("integrations.website.errorUpdate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -56,7 +58,7 @@ const WebsiteForm: FC = ({ callback, ticker }) => { - You can configure website origins for your ticker. The ticker will only be reachable from the configured websites. + {t("integrations.website.configureMessage")} @@ -85,7 +87,7 @@ const WebsiteForm: FC = ({ callback, ticker }) => { ))} - + diff --git a/src/components/ticker/WebsiteModalForm.tsx b/src/components/ticker/WebsiteModalForm.tsx index 85780370..f8c2f1c1 100644 --- a/src/components/ticker/WebsiteModalForm.tsx +++ b/src/components/ticker/WebsiteModalForm.tsx @@ -2,6 +2,7 @@ import { FC } from 'react' import { Ticker } from '../../api/Ticker' import Modal from '../common/Modal' import WebsiteForm from './WebsiteForm' +import { useTranslation } from 'react-i18next' interface Props { onClose: () => void @@ -10,8 +11,10 @@ interface Props { } const WebsiteModalForm: FC = ({ onClose, open, ticker }) => { + const { t } = useTranslation() + return ( - + ) diff --git a/src/components/ticker/form/Active.tsx b/src/components/ticker/form/Active.tsx index a4a1ff62..9f2cae94 100644 --- a/src/components/ticker/form/Active.tsx +++ b/src/components/ticker/form/Active.tsx @@ -1,19 +1,21 @@ import { Checkbox, FormControlLabel } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' interface Props { defaultChecked?: boolean } const Active: FC = ({ defaultChecked }) => { + const { t } = useTranslation() const { control } = useFormContext() return ( } label="Active" />} + render={({ field }) => } label={t("status.active")} />} /> ) } diff --git a/src/components/ticker/form/Author.tsx b/src/components/ticker/form/Author.tsx index ad7f107b..b0bc370c 100644 --- a/src/components/ticker/form/Author.tsx +++ b/src/components/ticker/form/Author.tsx @@ -2,9 +2,11 @@ import { faUser } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' +import { useTranslation } from 'react-i18next' import { Controller, useFormContext } from 'react-hook-form' const Author: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -23,7 +25,7 @@ const Author: FC = () => { ), }, }} - label="Author" + label={t("common.author")} margin="dense" /> )} diff --git a/src/components/ticker/form/Bluesky.tsx b/src/components/ticker/form/Bluesky.tsx index 7d37d184..3639c827 100644 --- a/src/components/ticker/form/Bluesky.tsx +++ b/src/components/ticker/form/Bluesky.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Bluesky: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -23,7 +25,7 @@ const Bluesky: FC = () => { ), }, }} - label="Bluesky" + label={t('integrations.bluesky.title')} margin="dense" /> )} diff --git a/src/components/ticker/form/Description.tsx b/src/components/ticker/form/Description.tsx index d2a6552a..a3a2c5a8 100644 --- a/src/components/ticker/form/Description.tsx +++ b/src/components/ticker/form/Description.tsx @@ -1,8 +1,10 @@ import { TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Description: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -18,7 +20,7 @@ const Description: FC = () => { maxRows={10} minRows={3} multiline - label="Description" + label={t("common.description")} /> )} /> diff --git a/src/components/ticker/form/Email.tsx b/src/components/ticker/form/Email.tsx index 653c1535..d96f3743 100644 --- a/src/components/ticker/form/Email.tsx +++ b/src/components/ticker/form/Email.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Email: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -15,7 +17,7 @@ const Email: FC = () => { required: false, pattern: { value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, - message: 'E-Mail is invalid', + message: t('user.errorEmailInvalid'), }, }} render={({ field, fieldState: { error } }) => ( @@ -32,7 +34,7 @@ const Email: FC = () => { }} error={!!error} helperText={error?.message ? error.message : null} - label="E-Mail" + label={t('user.email')} margin="dense" /> )} diff --git a/src/components/ticker/form/Facebook.tsx b/src/components/ticker/form/Facebook.tsx index 8109ee85..9771d7a0 100644 --- a/src/components/ticker/form/Facebook.tsx +++ b/src/components/ticker/form/Facebook.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Facebook: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -15,7 +17,7 @@ const Facebook: FC = () => { required: false, pattern: { value: /^([a-zA-Z0-9._]+)$/, - message: 'Invalid Facebook username', + message: t('social.errorFacebookUser') }, }} render={({ field, fieldState: { error } }) => ( @@ -33,7 +35,7 @@ const Facebook: FC = () => { }} error={!!error} helperText={error?.message ? error.message : null} - label="Facebook" + label={t('social.facebook')} margin="dense" /> )} diff --git a/src/components/ticker/form/Instagram.tsx b/src/components/ticker/form/Instagram.tsx index 8286c5f2..b1c627c2 100644 --- a/src/components/ticker/form/Instagram.tsx +++ b/src/components/ticker/form/Instagram.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Instagram: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -15,7 +17,7 @@ const Instagram: FC = () => { required: false, pattern: { value: /^([a-zA-Z0-9._]+)$/, - message: 'Invalid Instagram username.', + message: t('social.errorInstagramUser') }, }} render={({ field, fieldState: { error } }) => ( @@ -33,7 +35,7 @@ const Instagram: FC = () => { }} error={!!error} helperText={error?.message ? error.message : null} - label="Instagram" + label={t('social.instagram')} margin="dense" /> )} diff --git a/src/components/ticker/form/Mastodon.tsx b/src/components/ticker/form/Mastodon.tsx index 6f29256b..d73ff532 100644 --- a/src/components/ticker/form/Mastodon.tsx +++ b/src/components/ticker/form/Mastodon.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Mastodon: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -23,7 +25,7 @@ const Mastodon: FC = () => { ), }, }} - label="Mastodon" + label={t('integrations.mastodon.title')} margin="dense" /> )} diff --git a/src/components/ticker/form/Telegram.tsx b/src/components/ticker/form/Telegram.tsx index ce5fd81e..ef0b1795 100644 --- a/src/components/ticker/form/Telegram.tsx +++ b/src/components/ticker/form/Telegram.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Telegram: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -15,7 +17,7 @@ const Telegram: FC = () => { required: false, pattern: { value: /^\w{5,32}$/, - message: 'Invalid Telegram username', + message: t("social.errorTelegramUser") }, }} render={({ field, fieldState: { error } }) => ( @@ -33,7 +35,7 @@ const Telegram: FC = () => { }} error={!!error} helperText={error?.message ? error.message : null} - label="Telegram" + label={t("integrations.telegram.title")} margin="dense" /> )} diff --git a/src/components/ticker/form/Threads.tsx b/src/components/ticker/form/Threads.tsx index e3866945..fb33e009 100644 --- a/src/components/ticker/form/Threads.tsx +++ b/src/components/ticker/form/Threads.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Threads: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -15,7 +17,7 @@ const Threads: FC = () => { required: false, pattern: { value: /^@([a-zA-Z0-9._]+)$/, - message: 'Invalid Threads username. Must start with @', + message: t('social.errorThreadsUsername'), }, }} render={({ field, fieldState: { error } }) => ( @@ -33,7 +35,7 @@ const Threads: FC = () => { }} error={!!error} helperText={error?.message ? error.message : null} - label="Threads" + label={t("social.threads")} margin="dense" /> )} diff --git a/src/components/ticker/form/TickerForm.tsx b/src/components/ticker/form/TickerForm.tsx index 7b68ba4f..3630cd73 100644 --- a/src/components/ticker/form/TickerForm.tsx +++ b/src/components/ticker/form/TickerForm.tsx @@ -2,6 +2,7 @@ import { FormGroup, Grid, Typography } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import { FC } from 'react' import { FormProvider, SubmitHandler, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' import { handleApiCall } from '../../../api/Api' import { postTickerApi, putTickerApi, Ticker, TickerFormData } from '../../../api/Ticker' import useAuth from '../../../contexts/useAuth' @@ -28,6 +29,7 @@ interface Props { } const TickerForm: FC = ({ callback, id, ticker, setSubmitting }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const form = useForm({ defaultValues: { @@ -61,11 +63,11 @@ const TickerForm: FC = ({ callback, id, ticker, setSubmitting }) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tickers'] }) queryClient.invalidateQueries({ queryKey: ['ticker', ticker?.id] }) - createNotification({ content: `Ticker was successfully ${ticker ? 'updated' : 'created'}`, severity: 'success' }) + createNotification({ content: t(ticker ? "tickers.updated" : "tickers.created"), severity: 'success' }) callback() }, onError: () => { - createNotification({ content: `Failed to ${ticker ? 'update' : 'create'} ticker`, severity: 'error' }) + createNotification({ content: t(ticker ? 'tickers.errorUpdate' : 'tickers.errorCreate'), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -96,7 +98,7 @@ const TickerForm: FC = ({ callback, id, ticker, setSubmitting }) => {
- Information + {t('common.information')} diff --git a/src/components/ticker/form/Title.tsx b/src/components/ticker/form/Title.tsx index 850b2277..e5a8d6b2 100644 --- a/src/components/ticker/form/Title.tsx +++ b/src/components/ticker/form/Title.tsx @@ -1,8 +1,10 @@ import { TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Title: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -10,12 +12,12 @@ const Title: FC = () => { name="title" control={control} rules={{ - required: 'Title is required', - minLength: { value: 3, message: 'Title is too short' }, - maxLength: { value: 255, message: 'Title is too long' }, + required: t("message.titleRequired"), + minLength: { value: 3, message: t("message.errorTitleShort") }, + maxLength: { value: 255, message: t("message.errorTitleLong") }, }} render={({ field, fieldState: { error } }) => ( - + )} /> ) diff --git a/src/components/ticker/form/Twitter.tsx b/src/components/ticker/form/Twitter.tsx index 6b1ce5e0..ed4c9411 100644 --- a/src/components/ticker/form/Twitter.tsx +++ b/src/components/ticker/form/Twitter.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Twitter: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -15,7 +17,7 @@ const Twitter: FC = () => { required: false, pattern: { value: /^([a-zA-Z0-9._]+)$/, - message: 'Invalid Twitter username', + message: t('social.errorTwitterUser'), }, }} render={({ field, fieldState: { error } }) => ( @@ -33,7 +35,7 @@ const Twitter: FC = () => { }} error={!!error} helperText={error?.message ? error.message : null} - label="Twitter" + label={t("social.twitter")} margin="dense" /> )} diff --git a/src/components/ticker/form/Url.tsx b/src/components/ticker/form/Url.tsx index 60b86306..5a06ab05 100644 --- a/src/components/ticker/form/Url.tsx +++ b/src/components/ticker/form/Url.tsx @@ -3,8 +3,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { InputAdornment, TextField } from '@mui/material' import { FC } from 'react' import { Controller, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' const Url: FC = () => { + const { t } = useTranslation() const { control } = useFormContext() return ( @@ -15,7 +17,7 @@ const Url: FC = () => { required: false, pattern: { value: /^(https?:\/\/)?([a-z0-9-]+\.)+[a-z]{2,}(:\d{1,5})?(\/.*)?$/i, - message: 'Homepage is invalid', + message: t("integrations.errorHomepage"), }, }} render={({ field, fieldState: { error } }) => ( @@ -32,7 +34,7 @@ const Url: FC = () => { }} error={!!error} helperText={error?.message ? error.message : null} - label="Homepage" + label={t("integrations.homepage")} margin="dense" /> )} diff --git a/src/components/user/UserChangePasswordForm.tsx b/src/components/user/UserChangePasswordForm.tsx index 7b9cae9f..35181dd4 100644 --- a/src/components/user/UserChangePasswordForm.tsx +++ b/src/components/user/UserChangePasswordForm.tsx @@ -1,5 +1,6 @@ import { Alert, FormGroup, Grid, TextField } from '@mui/material' import { FC } from 'react' +import { useTranslation } from 'react-i18next' import { SubmitHandler, useForm } from 'react-hook-form' import { handleApiCall } from '../../api/Api' import { putMeApi } from '../../api/User' @@ -19,6 +20,7 @@ interface FormValues { } const UserChangePasswordForm: FC = ({ id, onClose, setSubmitting }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { register, @@ -34,18 +36,18 @@ const UserChangePasswordForm: FC = ({ id, onClose, setSubmitting }) => { handleApiCall(putMeApi(token, data), { onSuccess: () => { - createNotification({ content: 'Password successfully updated', severity: 'success' }) + createNotification({ content: t("user.passwordUpdated"), severity: 'success' }) onClose() }, onError: response => { - const message = response.error?.message === 'could not authenticate password' ? 'Wrong password' : 'Something went wrong' + const message = t(response.error?.message === 'could not authenticate password' ? 'user.errorWrongPassword' : 'error.somethingWentWrong') setError('password', { type: 'custom', message: message, }) - createNotification({ content: 'Failed to update password', severity: 'error' }) + createNotification({ content: t('user.errorUpdatePassword'), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -63,7 +65,7 @@ const UserChangePasswordForm: FC = ({ id, onClose, setSubmitting }) => { {errors.password && {errors.password.message}} - + = ({ id, onClose, setSubmitting }) => { {...register('newPassword', { minLength: { value: 10, - message: 'Password must have at least 10 characters', + message: t('user.errorPasswordMinLength'), }, })} helperText={errors.newPassword?.message} - label="New Password" + label={t('user.newPassword')} type="password" /> value === newPassword || 'The passwords do not match', + validate: value => value === newPassword || t('user.errorPasswordMatch'), })} helperText={errors.newPasswordValidate?.message} - label="Repeat new Password" + label={t('user.repeatNewPassword')} required type="password" /> diff --git a/src/components/user/UserChangePasswordModalForm.tsx b/src/components/user/UserChangePasswordModalForm.tsx index d365e044..900a7831 100644 --- a/src/components/user/UserChangePasswordModalForm.tsx +++ b/src/components/user/UserChangePasswordModalForm.tsx @@ -1,6 +1,7 @@ import { FC, useState } from 'react' import Modal from '../common/Modal' import UserChangePasswordForm from './UserChangePasswordForm' +import { useTranslation } from 'react-i18next' interface Props { open: boolean @@ -8,9 +9,11 @@ interface Props { } const UserChangePasswordModalForm: FC = ({ open, onClose }) => { + const { t } = useTranslation() const [submitting, setSubmitting] = useState(false) + return ( - + ) diff --git a/src/components/user/UserForm.tsx b/src/components/user/UserForm.tsx index 904294a3..c87614f6 100644 --- a/src/components/user/UserForm.tsx +++ b/src/components/user/UserForm.tsx @@ -2,6 +2,7 @@ import { Checkbox, Divider, FormControlLabel, FormGroup, Grid, TextField, Typogr import { useQueryClient } from '@tanstack/react-query' import { FC, useEffect } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' import { handleApiCall } from '../../api/Api' import { Ticker } from '../../api/Ticker' import { User, postUserApi, putUserApi } from '../../api/User' @@ -25,6 +26,7 @@ interface FormValues { } const UserForm: FC = ({ id, user, callback, setSubmitting }) => { + const { t } = useTranslation() const { createNotification } = useNotification() const { token } = useAuth() const { @@ -59,11 +61,11 @@ const UserForm: FC = ({ id, user, callback, setSubmitting }) => { handleApiCall(apiCall, { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) - createNotification({ content: `User was successfully ${user ? 'updated' : 'created'}`, severity: 'success' }) + createNotification({ content: t(user ? "user.updated" : "user.created"), severity: 'success' }) callback() }, onError: () => { - createNotification({ content: `Failed to ${user ? 'update' : 'create'} user`, severity: 'error' }) + createNotification({ content: t(user ? "user.errorUpdate" : "user.errorCreate"), severity: 'error' }) }, onFailure: error => { createNotification({ content: error as string, severity: 'error' }) @@ -87,14 +89,14 @@ const UserForm: FC = ({ id, user, callback, setSubmitting }) => { margin="normal" {...register('email')} defaultValue={user ? user.email : ''} - label="E-Mail" + label={t('user.email')} name="email" required type="email" /> - } label="Super Admin" /> + } label={t('user.superAdmin')} /> @@ -104,12 +106,12 @@ const UserForm: FC = ({ id, user, callback, setSubmitting }) => { margin="normal" {...register('password', { minLength: { - value: 8, - message: 'Password must have at least 8 characters', + value: 10, + message: t('user.errorPasswordMinLength'), }, })} helperText={errors.password?.message} - label="Password" + label={t('user.password')} name="password" required={!user} type="password" @@ -118,10 +120,10 @@ const UserForm: FC = ({ id, user, callback, setSubmitting }) => { error={errors.password_validate !== undefined} margin="dense" {...register('password_validate', { - validate: value => value === password || 'The passwords do not match', + validate: value => value === password || t('user.errorPasswordMatch'), })} helperText={errors.password_validate?.message} - label="Repeat Password" + label={t('user.repeatPassword')} name="password_validate" required={!user} type="password" @@ -132,7 +134,7 @@ const UserForm: FC = ({ id, user, callback, setSubmitting }) => { - Permissions + {t('user.permissions')} { + const { t } = useTranslation() const { token } = useAuth() const { isLoading, error, data } = useUsersQuery({ token }) @@ -15,7 +17,7 @@ const UserList: FC = () => { } if (error || data === undefined || data.status === 'error') { - return Unable to fetch users from server. + return {t("user.errorUnableToFetch")} } const users = data.data?.users || [] @@ -26,19 +28,19 @@ const UserList: FC = () => { - ID + {t('common.ID')} - Admin + {t('common.admin')} - E-Mail + {t('user.email')} - Creation Time + {t('common.creationTime')} - Last Login + {t('user.lastLogin')} diff --git a/src/components/user/UserListItem.tsx b/src/components/user/UserListItem.tsx index acece01e..467d0bec 100644 --- a/src/components/user/UserListItem.tsx +++ b/src/components/user/UserListItem.tsx @@ -8,6 +8,11 @@ import React, { FC, useState } from 'react' import { User } from '../../api/User' import UserModalDelete from './UserModalDelete' import UserModalForm from './UserModalForm' +import localizedFormat from 'dayjs/plugin/localizedFormat' +import 'dayjs/locale/de' +import 'dayjs/locale/en' +import 'dayjs/locale/fr' +import { useTranslation } from 'react-i18next' interface Props { user: User @@ -16,6 +21,7 @@ interface Props { dayjs.extend(relativeTime) const UserListItem: FC = ({ user }) => { + const { t } = useTranslation() const [formModalOpen, setFormModalOpen] = useState(false) const [deleteModalOpen, setDeleteModalOpen] = useState(false) const [anchorEl, setAnchorEl] = useState(null) @@ -28,9 +34,18 @@ const UserListItem: FC = ({ user }) => { setAnchorEl(null) } + const { i18n } = useTranslation() + const normalizedLang = i18n.language.split('-')[0].toLowerCase() + let dayjsLocale = 'en' + if (['en', 'de', 'fr'].includes(normalizedLang)) { + dayjsLocale = normalizedLang + } + dayjs.extend(localizedFormat) + dayjs.locale(dayjsLocale) + const emptyDate = '0001-01-01T00:00:00Z' - const createdAt = dayjs(user.createdAt).format('MMM D, YYYY h:mm A') - const lastLogin = user.lastLogin !== emptyDate ? dayjs(user.lastLogin).fromNow() : 'never' + const createdAt = dayjs(user.createdAt).format('lll') + const lastLogin = user.lastLogin !== emptyDate ? dayjs(user.lastLogin).fromNow() : t("user.never") return ( @@ -72,7 +87,7 @@ const UserListItem: FC = ({ user }) => { }} > - Edit + {t("action.edit")} = ({ user }) => { sx={{ color: colors.red[400] }} > - Delete + {t("action.delete")} setFormModalOpen(false)} open={formModalOpen} user={user} /> diff --git a/src/components/user/UserModalDelete.tsx b/src/components/user/UserModalDelete.tsx index 2a446cac..d784ccb1 100644 --- a/src/components/user/UserModalDelete.tsx +++ b/src/components/user/UserModalDelete.tsx @@ -3,6 +3,7 @@ import { FC, useCallback } from 'react' import { User, deleteUserApi } from '../../api/User' import useAuth from '../../contexts/useAuth' import Modal from '../common/Modal' +import { useTranslation } from 'react-i18next' interface Props { onClose: () => void @@ -11,6 +12,7 @@ interface Props { } const UserModalDelete: FC = ({ onClose, open, user }) => { + const { t } = useTranslation() const { token } = useAuth() const queryClient = useQueryClient() @@ -22,8 +24,8 @@ const UserModalDelete: FC = ({ onClose, open, user }) => { }, [token, user, queryClient, onClose]) return ( - - Are you sure to delete the user? This action cannot be undone. + + {t("user.questionPermanentDelete")} ) } diff --git a/src/components/user/UserModalForm.tsx b/src/components/user/UserModalForm.tsx index a691cc23..f5d62b7d 100644 --- a/src/components/user/UserModalForm.tsx +++ b/src/components/user/UserModalForm.tsx @@ -1,4 +1,5 @@ import { FC, useState } from 'react' +import { useTranslation } from 'react-i18next' import { User } from '../../api/User' import Modal from '../common/Modal' import UserForm from './UserForm' @@ -10,9 +11,10 @@ interface Props { } const UserModalForm: FC = ({ open, onClose, user }) => { + const { t } = useTranslation() const [submitting, setSubmitting] = useState(false) return ( - + ) diff --git a/src/contexts/useAuth.test.tsx b/src/contexts/useAuth.test.tsx index b84abc66..4ac10a2a 100644 --- a/src/contexts/useAuth.test.tsx +++ b/src/contexts/useAuth.test.tsx @@ -30,7 +30,7 @@ describe('useAuth', () => { expect(() => { renderHook(() => useAuth()) - }).toThrow('useAuth must be used within a AuthProvider') + }).toThrow('useAuth must be used within an AuthProvider') // Restore console.error consoleSpy.mockRestore() diff --git a/src/contexts/useAuth.tsx b/src/contexts/useAuth.tsx index d36faf31..ea9f683e 100644 --- a/src/contexts/useAuth.tsx +++ b/src/contexts/useAuth.tsx @@ -4,7 +4,7 @@ import AuthContext from './AuthContext' const useAuth = () => { const context = useContext(AuthContext) if (!context) { - throw new Error('useAuth must be used within a AuthProvider') + throw new Error('useAuth must be used within an AuthProvider') } return context } diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts new file mode 100644 index 00000000..6b717b42 --- /dev/null +++ b/src/i18n/i18n.ts @@ -0,0 +1,35 @@ +import i18n from 'i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import { initReactI18next } from 'react-i18next' + +// Import translation files +import de from './locales/de.json' +import en from './locales/en.json' +import fr from './locales/fr.json' + +i18n + .use(LanguageDetector) // Use language detector + .use(initReactI18next) + .init({ + resources: { + de: { + translation: de, + }, + en: { + translation: en, + }, + fr: { + translation: fr, + }, + }, + fallbackLng: 'en', // Fallback to English if no translation exists + detection: { + order: ['navigator', 'htmlTag', 'path', 'subdomain'], + caches: ['localStorage', 'cookie'], + }, + interpolation: { + escapeValue: false, // React already escapes by default + }, + }) + +export default i18n diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/src/i18n/locales/de.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 00000000..89721940 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,254 @@ +{ + "action": { + "add": "Add", + "cancel": "Cancel", + "close": "Close", + "configure": "Configure", + "copyToClipboard": "Copy to Clipboard", + "delete": "Delete", + "disable": "Disable", + "edit": "Edit", + "enable": "Enable", + "handle": "Handle", + "reload": "Reload", + "save": "Save", + "send": "Send", + "use": "Use" + }, + "application": { + "accessToken": "Access Token", + "clientKey": "Client Key", + "clientSecret": "Client Secret", + "requiredScopes": "Required Scopes:", + "server": "Server" + }, + "common": { + "account": "account", + "admin": "Admin", + "author": "Author", + "channel": "channel", + "copied": "Copied!", + "creationTime": "Creation Time", + "dangerZone": "Danger Zone", + "description": "Description", + "general": "General", + "group": "group", + "headline": "Headline", + "ID": "ID", + "info": "Info", + "information": "Information", + "location": "Location", + "loading": "Loading", + "phone": "Phone number", + "reloading": "Reloading", + "subheadline": "Subheadline", + "webOrigins": "Web Origins" + }, + "error": { + "authentication": "Authentication failed", + "contactAdmin": "Please try again later or contact your administrator.", + "notFound": "Not found", + "ohNo": "Oh no! An error occurred", + "permissionDenied": "Permission denied", + "somethingWentWrong": "Something went wrong" + }, + "filter": { + "all": "All", + "byOrigin": "Filter by origin", + "byTitle": "Filter by title", + "origin": "Origin" + }, + "integrations": { + "bluesky": { + "connected": "You are connected with Bluesky.", + "configure": "Configure Bluesky", + "createAppPassword": "You need to create a application password in Bluesky.", + "deleted": "Bluesky integration successfully deleted", + "disabled": "Bluesky integration disabled successfully", + "enabled": "Bluesky integration enabled successfully", + "errorDelete": "Failed to delete Bluesky integration", + "errorUpdate": "Failed to update Bluesky integration", + "notConnected": "You are not connected with Bluesky.", + "title": "Bluesky", + "updated": "Bluesky integration was successfully updated" + }, + "errorHomepage": "Homepage is invalid", + "homepage": "Homepage", + "mastodon": { + "connected": "You are connected with Mastodon.", + "configure": "Configure Mastodon", + "createApplication": "You need to create a Application for Ticker in Mastodon. Go to your profile settings in Mastodon. You find a menu point \"Developer\" where you need to create an Application. After saving you see the required secrets and tokens.", + "createAppPassword": "You need to create a application password.", + "deleted": "Mastodon integration successfully deleted", + "disabled": "Mastodon integration disabled successfully", + "enabled": "Mastodon integration enabled successfully", + "errorDelete": "Failed to delete Mastodon integration", + "errorUpdate": "Failed to update Mastodon integration", + "notConnected": "You are not connected with Mastodon.", + "title": "Mastodon", + "updated": "Mastodon integration was successfully updated" + }, + "noIntegrations": "No integrations", + "noNewMessages": "New messages will not be published to your {{type}} and old messages can not be deleted anymore.", + "signal": { + "addAdmin": "Add Admin to group", + "connected": "You have a Signal group connected.", + "delete": "Delete Signal Group", + "deleted": "Signal Group deleted successfully", + "disabled": "Signal group disabled successfully", + "enabled": "Signal group enabled successfully", + "errorAddNumber": "Failed to add number to Signal group", + "errorConfigure": "Failed to configure Signal group", + "errorDelete": "Failed to delete Signal group", + "errorUpdate": "Failed to update Signal group", + "inviteLink": "Your Signal group invite link: {{link}}", + "notConnected": "You don't have a Signal group connected.", + "numberAdded": "Number successfully added to Signal group", + "onlyIfNeeded": "Only do this if extra members with write access are needed.", + "questionDelete": "Are you sure you want to delete the Signal group? This is irreversible.", + "title": "Signal Group" + }, + "telegram": { + "bot": "(Bot: {{bot}})", + "channel": "Channel", + "connected": "You are connected with Telegram.", + "configure": "Configure Telegram", + "deleted": "Telegram integration successfully deleted", + "disabled": "Telegram integration disabled successfully", + "enabled": "Telegram integration enabled successfully", + "errorDelete": "Failed to delete Telegram integration", + "errorNaming": "The Channel must start with an @", + "errorUpdate": "Failed to update Telegram integration", + "notConnected": "You are not connected with Telegram.", + "onlyPublic": "Only public Telegram Channels are supported. The name of the Channel is prefixed with an @ (e.g. @channel).", + "title": "Telegram", + "updated": "Telegram integration was successfully updated", + "yourChannel": "Your channel:" + }, + "website": { + "addOrigin": "Add Origin", + "allowed": "You have allowed the following websites to access your ticker: {{links}}", + "configure": "Configure Website", + "configureMessage": "You can configure website origins for your ticker. The ticker will only be reachable from the configured websites.", + "deleted": "Websites integration successfully deleted", + "errorDelete": "Failed to delete Websites integration", + "errorUpdate": "Failed to update websites", + "noOrigins": "No origins", + "noOriginsConfigured": "No website origins configured.", + "noOriginsMessage": "Without configured website origins, the ticker is not reachable from any website.", + "updated": "Websites were successfully updated" + }, + "yourProfile": "Your Profile:" + }, + "message": { + "deleted": "Message successfully deleted", + "errorFailedToDelete": "Failed to delete message", + "errorFailedToPost": "Failed to post message", + "errorRequired": "The message is required.", + "errorTitleLong": "Title is too long", + "errorTitleShort": "Title is too short", + "errorTooLong": "The message is too long.", + "errorUnableToFetch": "Unable to fetch messages from server.", + "loadMore": "Load More", + "posted": "Message successfully posted", + "questionDelete": "Are you sure to delete the message? This action cannot be undone.", + "reload": "Reload Messages", + "titleRequired": "Title is required", + "writeActive": "Write a message", + "writeInactive": "You can't post messages to inactive tickers." + }, + "social": { + "errorFacebookUser": "Invalid Facebook username", + "errorInstagramUser": "Invalid Instagram username", + "errorTelegramUser": "Invalid Telegram username", + "errorThreadsUsername": "Invalid Threads username. Must start with @", + "errorTwitterUser": "Invalid Twitter username", + "facebook": "Facebook", + "instagram": "Instagram", + "threads": "Threads", + "twitter": "Twitter" + }, + "status": { + "active": "Active", + "description": "These settings have affect for inactive or non-configured tickers.", + "editInactive": "Edit Inactive Settings", + "inactive": "Inactive", + "showActive": "Status: Active", + "showInactive": "Status: Inactive" + }, + "title": { + "dashboard": "Dashboard", + "inactive": "Inactive Settings", + "integrations": "Integrations", + "settings": "Settings", + "ticker": "Ticker", + "tickers": "Tickers", + "title": "Title", + "users": "Users" + }, + "tickers": { + "configure": "Configure Ticker", + "create": "Create Ticker", + "created": "Ticker was successfully created", + "delete": "Delete Ticker", + "deleted": "Ticker was successfully deleted", + "disabled": "This ticker is currently disabled.", + "error0Found": "No tickers found.", + "errorCreate": "Failed to create ticker", + "errorDelete": "Failed to delete ticker", + "errorFetch": "Failed to fetch tickers", + "errorNotFound": "Ticker not found.", + "errorReset": "Failed to reset ticker", + "errorUnableToFetch": "Unable to fetch tickers from server.", + "errorUpdate": "Failed to update ticker", + "new": "New Ticker", + "questionDelete": "Are you sure to delete the ticker? This action cannot be undone.", + "questionReset": "Are you sure you want to reset the ticker?", + "reset": "Reset Ticker", + "reseted": "Ticker has been successfully reset", + "resetMessage": "This will remove all messages, descriptions and disable the ticker.", + "updated": "Ticker was successfully updated" + }, + "user": { + "changePassword": "Change Password", + "create": "Create User", + "created": "User was successfully created", + "delete": "Delete User", + "deleted": "User is successfully deleted from ticker", + "email": "E-Mail", + "errorAccess": "There are no users granted access this ticker.", + "errorCreate": "Failed to create user", + "errorDelete": "Failed to delete user from ticker", + "errorEmailInvalid": "E-Mail is invalid", + "errorFetch": "Failed to fetch users", + "errorPasswordMatch": "The passwords do not match", + "errorPasswordMinLength": "Password must have at least 10 characters", + "errorUnableToFetch": "Unable to fetch users from server.", + "errorUpdate": "Failed to update user", + "errorUpdateMultiple": "Failed to update users", + "errorUpdatePassword": "Failed to update password", + "errorWrongPassword": "Wrong password", + "lastLogin": "Last Login", + "list": "List of all granted users to this ticker. Only Admins can manage this list.", + "login": "Login", + "loggedOut": "You have been logged out", + "logout": "Logout", + "manage": "Manage Users", + "manageAccess": "Manage User Access", + "never": "never", + "new": "New User", + "newPassword": "New Password", + "password": "Password", + "passwordUpdated": "Password successfully updated", + "permissions": "Permissions", + "questionDelete": "Are you sure to remove {{user}} from this ticker?", + "questionPermanentDelete": "Are you sure to delete the user? This action cannot be undone.", + "repeatNewPassword": "Repeat new Password", + "repeatPassword": "Repeat Password", + "superAdmin": "Super Admin", + "tickerLogin": "Ticker Login", + "update": "Update User", + "updated": "User was successfully updated", + "updatedMultiple": "Users were successfully updated" + } +} \ No newline at end of file diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json new file mode 100644 index 00000000..bc5dfee3 --- /dev/null +++ b/src/i18n/locales/fr.json @@ -0,0 +1,254 @@ +{ + "action": { + "add": "Ajouter", + "cancel": "Annuler", + "close": "Fermer", + "configure": "Configurer", + "copyToClipboard": "Copier dans le presse-papiers", + "delete": "Supprimer", + "disable": "Désactiver", + "edit": "Modifier", + "enable": "Activer", + "handle": "Gérer", + "reload": "Recharger", + "save": "Enregistrer", + "send": "Envoyer", + "use": "Utiliser" + }, + "application": { + "accessToken": "Jeton d'Accès", + "clientKey": "Clé Client", + "clientSecret": "Secret Client", + "requiredScopes": "Portées Requises :", + "server": "Serveur" + }, + "common": { + "account": "compte", + "admin": "Administrateur", + "author": "Auteur", + "channel": "canal", + "copied": "Copié !", + "creationTime": "Date de création", + "dangerZone": "Zone de Danger", + "description": "Description", + "general": "Général", + "group": "groupe", + "headline": "Titre principal", + "ID": "ID", + "info": "Info", + "information": "Informations", + "location": "Localisation", + "loading": "Chargement", + "phone": "Numéro de téléphone", + "reloading": "Rechargement", + "subheadline": "Sous-titre", + "webOrigins": "Origines Web" + }, + "error": { + "authentication": "Échec de l'authentification", + "contactAdmin": "Veuillez réessayer plus tard ou contacter votre administrateur.", + "notFound": "Non trouvé", + "ohNo": "Oh non ! Une erreur s'est produite", + "permissionDenied": "Permission refusée", + "somethingWentWrong": "Quelque chose s'est mal passé" + }, + "filter": { + "all": "Tous", + "byOrigin": "Filtrer par origine", + "byTitle": "Filtrer par titre", + "origin": "Origine" + }, + "integrations": { + "bluesky": { + "connected": "Vous êtes connecté à Bluesky.", + "configure": "Configurer Bluesky", + "createAppPassword": "Vous devez créer un mot de passe d'application dans Bluesky.", + "deleted": "Intégration Bluesky supprimée avec succès", + "disabled": "Intégration Bluesky désactivée avec succès", + "enabled": "Intégration Bluesky activée avec succès", + "errorDelete": "Échec de la suppression de l'intégration Bluesky", + "errorUpdate": "Échec de la mise à jour de l'intégration Bluesky", + "notConnected": "Vous n'êtes pas connecté à Bluesky.", + "title": "Bluesky", + "updated": "L'intégration Bluesky a été mise à jour avec succès" + }, + "errorHomepage": "La page d'accueil est invalide", + "homepage": "Page d'accueil", + "mastodon": { + "connected": "Vous êtes connecté à Mastodon.", + "configure": "Configurer Mastodon", + "createApplication": "Vous devez créer une application pour Ticker dans Mastodon. Allez dans les paramètres de votre profil dans Mastodon. Vous trouverez un point de menu \"Développeur\" où vous devez créer une application. Après avoir enregistré, vous verrez les secrets et jetons requis.", + "createAppPassword": "Vous devez créer un mot de passe d'application.", + "deleted": "Intégration Mastodon supprimée avec succès", + "disabled": "Intégration Mastodon désactivée avec succès", + "enabled": "Intégration Mastodon activée avec succès", + "errorDelete": "Échec de la suppression de l'intégration Mastodon", + "errorUpdate": "Échec de la mise à jour de l'intégration Mastodon", + "notConnected": "Vous n'êtes pas connecté à Mastodon.", + "title": "Mastodon", + "updated": "L'intégration Mastodon a été mise à jour avec succès" + }, + "noIntegrations": "Aucune intégration", + "noNewMessages": "Les nouveaux messages ne seront pas publiés sur votre {{type}} et les anciens messages ne peuvent plus être supprimés.", + "signal": { + "addAdmin": "Ajouter un administrateur au groupe", + "connected": "Vous avez un groupe Signal connecté.", + "delete": "Supprimer le groupe Signal", + "deleted": "Groupe Signal supprimé avec succès", + "disabled": "Groupe Signal désactivé avec succès", + "enabled": "Groupe Signal activé avec succès", + "errorAddNumber": "Échec de l'ajout du numéro au groupe Signal", + "errorConfigure": "Échec de la configuration du groupe Signal", + "errorDelete": "Échec de la suppression du groupe Signal", + "errorUpdate": "Échec de la mise à jour du groupe Signal", + "inviteLink": "Votre lien d'invitation de groupe Signal : {{link}}", + "notConnected": "Vous n'avez pas de groupe Signal connecté.", + "numberAdded": "Numéro ajouté avec succès au groupe Signal", + "onlyIfNeeded": "Faites cela seulement si des membres supplémentaires avec un accès en écriture sont nécessaires.", + "questionDelete": "Êtes-vous sûr de vouloir supprimer le groupe Signal ? Cela est irréversible.", + "title": "Groupe Signal" + }, + "telegram": { + "bot": "(Bot : {{bot}})", + "channel": "Canal", + "connected": "Vous êtes connecté à Telegram.", + "configure": "Configurer Telegram", + "deleted": "Intégration Telegram supprimée avec succès", + "disabled": "Intégration Telegram désactivée avec succès", + "enabled": "Intégration Telegram activée avec succès", + "errorDelete": "Échec de la suppression de l'intégration Telegram", + "errorNaming": "Le canal doit commencer par un @", + "errorUpdate": "Échec de la mise à jour de l'intégration Telegram", + "notConnected": "Vous n'êtes pas connecté à Telegram.", + "onlyPublic": "Seuls les canaux Telegram publics sont pris en charge. Le nom du canal est préfixé avec un @ (par exemple @channel).", + "title": "Telegram", + "updated": "L'intégration Telegram a été mise à jour avec succès", + "yourChannel": "Votre canal :" + }, + "website": { + "addOrigin": "Ajouter une origine", + "allowed": "Vous avez autorisé les sites Web suivants à accéder à votre Ticker : {{links}}", + "configure": "Configurer le site Web", + "configureMessage": "Vous pouvez configurer les origines de site Web pour votre Ticker. Le Ticker ne sera accessible que depuis les sites Web configurés.", + "deleted": "Intégration de sites Web supprimée avec succès", + "errorDelete": "Échec de la suppression de l'intégration de sites Web", + "errorUpdate": "Échec de la mise à jour des sites Web", + "noOrigins": "Pas d'origine", + "noOriginsConfigured": "Aucune origine de site Web configurée.", + "noOriginsMessage": "Sans origines de site Web configurées, le Ticker n'est accessible à partir d'aucun site Web.", + "updated": "Les sites Web ont été mis à jour avec succès" + }, + "yourProfile": "Votre profil :" + }, + "message": { + "deleted": "Message supprimé avec succès", + "errorFailedToDelete": "Échec de la suppression du message", + "errorFailedToPost": "Échec de l'envoi du message", + "errorRequired": "Le message est requis.", + "errorTitleLong": "Le titre est trop long", + "errorTitleShort": "Le titre est trop court", + "errorTooLong": "Le message est trop long.", + "errorUnableToFetch": "Impossible de récupérer les messages depuis le serveur.", + "loadMore": "Charger Plus", + "posted": "Message publié avec succès", + "questionDelete": "Êtes-vous sûr de vouloir supprimer le message ? Cette action ne peut pas être annulée.", + "reload": "Recharger les messages", + "titleRequired": "Le titre est requis", + "writeActive": "Rédiger un message", + "writeInactive": "Vous ne pouvez pas publier de messages sur les Tickers inactifs." + }, + "social": { + "errorFacebookUser": "Nom d'utilisateur Facebook invalide", + "errorInstagramUser": "Nom d'utilisateur Instagram invalide", + "errorTelegramUser": "Nom d'utilisateur Telegram invalide", + "errorThreadsUsername": "Nom d'utilisateur Threads invalide. Doit commencer par @", + "errorTwitterUser": "Nom d'utilisateur Twitter invalide", + "facebook": "Facebook", + "instagram": "Instagram", + "threads": "Threads", + "twitter": "Twitter" + }, + "status": { + "active": "Actif", + "description": "Ces paramètres ont un effet pour les Tickers inactifs ou non configurés.", + "editInactive": "Modifier les paramètres inactifs", + "inactive": "Inactif", + "showActive": "Statut : Actif", + "showInactive": "Statut : Inactif" + }, + "title": { + "dashboard": "Tableau de bord", + "inactive": "Paramètres Inactifs", + "integrations": "Intégrations", + "settings": "Paramètres", + "ticker": "Ticker", + "tickers": "Tickers", + "title": "Titre", + "users": "Utilisateurs" + }, + "tickers": { + "configure": "Configurer le Ticker", + "create": "Créer un Ticker", + "created": "Le Ticker a été créé avec succès", + "delete": "Supprimer le Ticker", + "deleted": "Le Ticker a été supprimé avec succès", + "disabled": "Ce Ticker est actuellement désactivé.", + "error0Found": "Aucun Ticker trouvé.", + "errorCreate": "Échec de la création du Ticker", + "errorDelete": "Échec de la suppression du Ticker", + "errorFetch": "Échec de la récupération des Tickers", + "errorNotFound": "Ticker non trouvé", + "errorReset": "Échec de la réinitialisation du Ticker", + "errorUnableToFetch": "Impossible de récupérer les Tickers du serveur.", + "errorUpdate": "Échec de la mise à jour du Ticker", + "new": "Nouveau Ticker", + "questionDelete": "Êtes-vous sûr de vouloir supprimer le Ticker ? Cette action ne peut pas être annulée.", + "questionReset": "Êtes-vous sûr de vouloir réinitialiser le Ticker ?", + "reset": "Réinitialiser le Ticker", + "reseted": "Le Ticker a été réinitialisé avec succès", + "resetMessage": "Cela supprimera tous les messages, descriptions et désactivera le Ticker.", + "updated": "Le Ticker a été mis à jour avec succès" + }, + "user": { + "changePassword": "Changer le mot de passe", + "create": "Créer un utilisateur", + "created": "L'utilisateur a été créé avec succès", + "delete": "Supprimer l'utilisateur", + "deleted": "L'utilisateur a été supprimé avec succès du Ticker", + "email": "E-mail", + "errorAccess": "Aucun utilisateur n'a accès à ce Ticker.", + "errorCreate": "Échec de la création de l'utilisateur", + "errorDelete": "Échec de la suppression de l'utilisateur du Ticker", + "errorEmailInvalid": "L'e-mail est invalide", + "errorFetch": "Échec de la récupération des utilisateurs", + "errorPasswordMatch": "Les mots de passe ne correspondent pas", + "errorPasswordMinLength": "Le mot de passe doit contenir au moins 10 caractères", + "errorUnableToFetch": "Impossible de récupérer les utilisateurs du serveur.", + "errorUpdate": "Échec de la mise à jour de l'utilisateur", + "errorUpdateMultiple": "Échec de la mise à jour des utilisateurs", + "errorUpdatePassword": "Échec de la mise à jour du mot de passe", + "errorWrongPassword": "Mot de passe incorrect", + "lastLogin": "Dernière connexion", + "list": "Liste de tous les utilisateurs autorisés à accéder à ce Ticker. Seuls les administrateurs peuvent gérer cette liste.", + "login": "Connexion", + "loggedOut": "Vous avez été déconnecté", + "logout": "Déconnexion", + "manage": "Gérer les utilisateurs", + "manageAccess": "Gérer l'accès utilisateur", + "never": "jamais", + "new": "Nouvel utilisateur", + "newPassword": "Nouveau mot de passe", + "password": "Mot de passe", + "passwordUpdated": "Mot de passe mis à jour avec succès", + "permissions": "Permissions", + "questionDelete": "Êtes-vous sûr de vouloir supprimer {{user}} de ce Ticker ?", + "questionPermanentDelete": "Êtes-vous sûr de vouloir supprimer l'utilisateur ? Cette action ne peut pas être annulée.", + "repeatNewPassword": "Répéter le nouveau mot de passe", + "repeatPassword": "Répéter le mot de passe", + "superAdmin": "Super Administrateur", + "tickerLogin": "Connexion au Ticker", + "update": "Mettre à jour l'utilisateur", + "updated": "L'utilisateur a été mis à jour avec succès", + "updatedMultiple": "Les utilisateurs ont été mis à jour avec succès" + } +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 6a15774e..3f2a6aea 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' +import './i18n/i18n' import '@fontsource/roboto/300.css' import '@fontsource/roboto/400.css' import '@fontsource/roboto/500.css' diff --git a/src/views/ErrorView.tsx b/src/views/ErrorView.tsx index f3283c0c..ba549973 100644 --- a/src/views/ErrorView.tsx +++ b/src/views/ErrorView.tsx @@ -1,6 +1,7 @@ import { Box, Button, Card, CardContent, colors, Divider, Stack, Typography } from '@mui/material' import { QueryKey, useQueryClient } from '@tanstack/react-query' import { FC } from 'react' +import { useTranslation } from 'react-i18next' interface Props { children: React.ReactNode @@ -8,6 +9,7 @@ interface Props { } const ErrorView: FC = ({ children, queryKey }) => { + const { t } = useTranslation() const queryClient = useQueryClient() const handleClick = () => { @@ -18,13 +20,13 @@ const ErrorView: FC = ({ children, queryKey }) => { - Oh no! An error occured + {t('error.ohNo')} @@ -32,7 +34,7 @@ const ErrorView: FC = ({ children, queryKey }) => { {children} - Please try again later or contact your administrator. + {t('error.contactAdmin')} diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx index e67919e2..49f82488 100644 --- a/src/views/HomeView.tsx +++ b/src/views/HomeView.tsx @@ -1,5 +1,6 @@ import { FC } from 'react' import { Navigate, useSearchParams } from 'react-router' +import { useTranslation } from 'react-i18next' import Loader from '../components/Loader' import useAuth from '../contexts/useAuth' import useTickersQuery from '../queries/useTickersQuery' @@ -8,6 +9,7 @@ import Layout from './Layout' import TickerListView from './TickerListView' const HomeView: FC = () => { + const { t } = useTranslation() const { token, user } = useAuth() const [params] = useSearchParams() const { data, error, isLoading } = useTickersQuery({ @@ -33,7 +35,7 @@ const HomeView: FC = () => { return ( -

Unable to fetch tickers from server.

+

{t('tickers.errorUnableToFetch')}

) diff --git a/src/views/Layout.tsx b/src/views/Layout.tsx index f669a2de..f6c227f6 100644 --- a/src/views/Layout.tsx +++ b/src/views/Layout.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Box, Container } from '@mui/material' import { FC } from 'react' import { useLocation } from 'react-router' +import { useTranslation } from 'react-i18next' import Nav from '../components/navigation/Nav' import NavItem from '../components/navigation/NavItem' import UserDropdown from '../components/navigation/UserDropdown' @@ -13,6 +14,7 @@ interface Props { } const Layout: FC = ({ children }) => { + const { t } = useTranslation() const { user } = useAuth() const location = useLocation() @@ -20,12 +22,12 @@ const Layout: FC = ({ children }) => { <>