feat: add internationalization support

This commit is contained in:
AshAnand34
2025-07-12 23:02:35 -07:00
parent 3b702b260c
commit f22bb8bd57
149 changed files with 2807 additions and 1045 deletions

View File

@@ -18,6 +18,7 @@ import { useNavigate } from 'react-router-dom';
import _ from 'lodash';
import { Icon } from '@iconify/react';
import { getToolCategoryTitle } from '@utils/string';
import { useTranslation } from 'react-i18next';
const GroupHeader = styled('div')(({ theme }) => ({
position: 'sticky',
@@ -48,6 +49,7 @@ const exampleTools: { label: string; url: string }[] = [
{ label: 'Calculate number sum', url: '/number/sum' }
];
export default function Hero() {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState<string>('');
const theme = useTheme();
const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(tools);
@@ -64,13 +66,13 @@ export default function Hero() {
<Box width={{ xs: '90%', md: '80%', lg: '60%' }}>
<Stack mb={1} direction={'row'} spacing={1} justifyContent={'center'}>
<Typography sx={{ textAlign: 'center' }} fontSize={{ xs: 25, md: 30 }}>
Get Things Done Quickly with{' '}
{t('hero.title')}{' '}
<Typography
fontSize={{ xs: 25, md: 30 }}
display={'inline'}
color={'primary'}
>
OmniTools
{t('hero.brand')}
</Typography>
</Typography>
</Stack>
@@ -79,9 +81,7 @@ export default function Hero() {
fontSize={{ xs: 15, md: 20 }}
mb={2}
>
Boost your productivity with OmniTools, the ultimate toolkit for getting
things done quickly! Access thousands of user-friendly utilities for
editing images, text, lists, and data, all directly from your browser.
{t('hero.description')}
</Typography>
<Autocomplete
@@ -103,7 +103,7 @@ export default function Hero() {
<TextField
{...params}
fullWidth
placeholder={'Search all tools'}
placeholder={t('hero.searchPlaceholder')}
InputProps={{
...params.InputProps,
endAdornment: <SearchIcon />,

View File

@@ -19,6 +19,7 @@ import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import { Icon } from '@iconify/react';
import { Mode } from 'components/App';
import { useTranslation } from 'react-i18next';
interface NavbarProps {
mode: Mode;
@@ -29,6 +30,7 @@ const Navbar: React.FC<NavbarProps> = ({
mode,
onChangeMode: onChangeMode
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -83,7 +85,7 @@ const Navbar: React.FC<NavbarProps> = ({
/>
}
>
Buy me a coffee
{t('navbar.buyMeACoffee')}
</Button>
];
const drawerList = (

View File

@@ -7,6 +7,7 @@ import { Icon, IconifyIcon } from '@iconify/react';
import { categoriesColors } from '../config/uiConfig';
import { getToolsByCategory } from '@tools/index';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const StyledButton = styled(Button)(({ theme }) => ({
backgroundColor: 'white',
@@ -24,6 +25,7 @@ interface ToolHeaderProps {
}
function ToolLinks() {
const { t } = useTranslation();
const [examplesVisible, setExamplesVisible] = useState(false);
useEffect(() => {
@@ -63,7 +65,7 @@ function ToolLinks() {
sx={{ backgroundColor: 'background.paper' }}
onClick={() => scrollToElement('examples')}
>
See Examples
{t('toolHeader.seeExamples')}
</StyledButton>
</Grid>
)}

View File

@@ -7,20 +7,33 @@ import AllTools from './allTools/AllTools';
import { getToolsByCategory } from '@tools/index';
import { capitalizeFirstLetter } from '../utils/string';
import { IconifyIcon } from '@iconify/react';
import { useTranslation } from 'react-i18next';
export default function ToolLayout({
children,
title,
description,
icon,
type
type,
i18n
}: {
title: string;
description: string;
icon?: IconifyIcon | string;
type: string;
children: ReactNode;
i18n?: {
name: string;
description: string;
shortDescription: string;
};
}) {
const { t } = useTranslation();
// Use i18n keys if available, otherwise fall back to provided strings
const toolTitle = i18n ? t(i18n.name) : title;
const toolDescription = i18n ? t(i18n.description) : description;
const otherCategoryTools =
getToolsByCategory()
.find((category) => category.type === type)
@@ -41,22 +54,24 @@ export default function ToolLayout({
sx={{ backgroundColor: 'background.default' }}
>
<Helmet>
<title>{`${title} - OmniTools`}</title>
<title>{`${toolTitle} - OmniTools`}</title>
</Helmet>
<Box width={'85%'}>
<ToolHeader
title={title}
description={description}
title={toolTitle}
description={toolDescription}
icon={icon}
type={type}
/>
{children}
<Separator backgroundColor="#5581b5" margin="50px" />
<AllTools
title={`All ${capitalizeFirstLetter(
getToolsByCategory().find((category) => category.type === type)!
.rawTitle
)} tools`}
title={t('toolLayout.allToolsTitle', {
type: capitalizeFirstLetter(
getToolsByCategory().find((category) => category.type === type)!
.rawTitle
)
})}
toolCards={otherCategoryTools}
/>
</Box>

View File

@@ -3,6 +3,7 @@ import ExampleCard, { ExampleCardProps } from './ExampleCard';
import React from 'react';
import { GetGroupsType } from '@components/options/ToolOptions';
import { useFormikContext } from 'formik';
import { useTranslation } from 'react-i18next';
export type CardExampleType<T> = Omit<
ExampleCardProps<T>,
@@ -24,6 +25,7 @@ export default function ToolExamples<T>({
getGroups,
setInput
}: ExampleProps<T>) {
const { t } = useTranslation();
const { setValues } = useFormikContext<T>();
function changeInputResult(newInput: string | undefined, newOptions: T) {
@@ -39,10 +41,10 @@ export default function ToolExamples<T>({
<Box id={'examples'} mt={4}>
<Box mt={4} display="flex" gap={1} alignItems="center">
<Typography mb={2} fontSize={30} color={'primary'}>
{`${title} Examples`}
{t('toolExamples.title', { title })}
</Typography>
<Typography mb={2} fontSize={30} color={'secondary'}>
{subtitle ?? 'Click to try!'}
{subtitle ?? t('toolExamples.subtitle')}
</Typography>
</Box>

View File

@@ -12,6 +12,7 @@ import { globalInputHeight } from '../../config/uiConfig';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import greyPattern from '@assets/grey-pattern.png';
import { isArray } from 'lodash';
import { useTranslation } from 'react-i18next';
interface BaseFileInputComponentProps extends BaseFileInputProps {
children: (props: { preview: string | undefined }) => ReactNode;
@@ -26,6 +27,7 @@ export default function BaseFileInput({
children,
type
}: BaseFileInputComponentProps) {
const { t } = useTranslation();
const [preview, setPreview] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
const theme = useTheme();
@@ -60,9 +62,9 @@ export default function BaseFileInput({
navigator.clipboard
.write([clipboardItem])
.then(() => showSnackBar('File copied', 'success'))
.then(() => showSnackBar(t('baseFileInput.fileCopied'), 'success'))
.catch((err) => {
showSnackBar('Failed to copy: ' + err, 'error');
showSnackBar(t('baseFileInput.copyFailed', { error: err }), 'error');
});
}
};
@@ -190,7 +192,7 @@ export default function BaseFileInput({
variant="h6"
align="center"
>
Drop your {type} here
{t('baseFileInput.dropFileHere', { type })}
</Typography>
) : (
<Typography
@@ -200,9 +202,7 @@ export default function BaseFileInput({
: theme.palette.grey['600']
}
>
Click here to select a {type} from your device, press Ctrl+V to
use a {type} from your clipboard, or drag and drop a file from
desktop
{t('baseFileInput.selectFileDescription', { type })}
</Typography>
)}
</Box>

View File

@@ -3,6 +3,7 @@ import Button from '@mui/material/Button';
import PublishIcon from '@mui/icons-material/Publish';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import ClearIcon from '@mui/icons-material/Clear';
import { useTranslation } from 'react-i18next';
export default function InputFooter({
handleImport,
@@ -13,19 +14,21 @@ export default function InputFooter({
handleCopy?: () => void;
handleClear?: () => void;
}) {
const { t } = useTranslation();
return (
<Stack mt={1} direction={'row'} spacing={2}>
<Button onClick={handleImport} startIcon={<PublishIcon />}>
Import from file
{t('inputFooter.importFromFile')}
</Button>
{handleCopy && (
<Button onClick={handleCopy} startIcon={<ContentPasteIcon />}>
Copy to clipboard
{t('inputFooter.copyToClipboard')}
</Button>
)}
{handleClear && (
<Button onClick={handleClear} startIcon={<ClearIcon />}>
Clear
{t('inputFooter.clear')}
</Button>
)}
</Stack>

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Grid, Select, MenuItem } from '@mui/material';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import Qty from 'js-quantities';
import { useTranslation } from 'react-i18next';
//
const siPrefixes: { [key: string]: number } = {
@@ -23,6 +24,7 @@ export default function NumericInputWithUnit(props: {
onOwnChange?: (value: { value: number; unit: string }) => void;
defaultPrefix?: string;
}) {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState(props.value.value);
const [prefix, setPrefix] = useState(props.defaultPrefix || 'Default prefix');
@@ -158,7 +160,7 @@ export default function NumericInputWithUnit(props: {
<Select
fullWidth
disabled={disableChangingUnit}
placeholder={'Unit'}
placeholder={t('numericInputWithUnit.unit')}
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
value={unit}
onChange={(event) => {

View File

@@ -6,6 +6,7 @@ import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { isArray } from 'lodash';
import MusicNoteIcon from '@mui/icons-material/MusicNote';
import { useTranslation } from 'react-i18next';
interface MultiAudioInputComponentProps {
accept: string[];
@@ -27,7 +28,10 @@ export default function ToolMultipleAudioInput({
title,
type
}: MultiAudioInputComponentProps) {
const { t } = useTranslation();
const theme = useTheme();
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
@@ -93,7 +97,12 @@ export default function ToolMultipleAudioInput({
return (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
title={
title ||
t('toolMultipleAudioInput.inputTitle', {
type: type.charAt(0).toUpperCase() + type.slice(1)
})
}
/>
<Box
sx={{
@@ -152,7 +161,7 @@ export default function ToolMultipleAudioInput({
))
) : (
<Typography variant="body2" color="text.secondary">
No files selected
{t('toolMultipleAudioInput.noFilesSelected')}
</Typography>
)}
</Box>

View File

@@ -6,6 +6,7 @@ import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { isArray } from 'lodash';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { useTranslation } from 'react-i18next';
interface MultiPdfInputComponentProps {
accept: string[];
@@ -27,6 +28,7 @@ export default function ToolMultiFileInput({
title,
type
}: MultiPdfInputComponentProps) {
const { t } = useTranslation();
const theme = useTheme();
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
@@ -96,7 +98,12 @@ export default function ToolMultiFileInput({
return (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
title={
title ||
t('toolMultiplePdfInput.inputTitle', {
type: type.charAt(0).toUpperCase() + type.slice(1)
})
}
/>
<Box
sx={{
@@ -156,7 +163,7 @@ export default function ToolMultiFileInput({
))
) : (
<Typography variant="body2" color="text.secondary">
No files selected
{t('toolMultiplePdfInput.noFilesSelected')}
</Typography>
)}
</Box>

View File

@@ -3,6 +3,7 @@ import React, { useContext, useRef } from 'react';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import { useTranslation } from 'react-i18next';
export default function ToolTextInput({
value,
@@ -15,15 +16,16 @@ export default function ToolTextInput({
onChange: (value: string) => void;
placeholder?: string;
}) {
const { t } = useTranslation();
const { showSnackBar } = useContext(CustomSnackBarContext);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleCopy = () => {
navigator.clipboard
.writeText(value)
.then(() => showSnackBar('Text copied', 'success'))
.then(() => showSnackBar(t('toolTextInput.copied'), 'success'))
.catch((err) => {
showSnackBar('Failed to copy: ' + err, 'error');
showSnackBar(t('toolTextInput.copyFailed', { error: err }), 'error');
});
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -45,14 +47,14 @@ export default function ToolTextInput({
};
return (
<Box>
<InputHeader title={title} />
<InputHeader title={title || t('toolTextInput.input')} />
<TextField
value={value}
onChange={(event) => onChange(event.target.value)}
fullWidth
multiline
rows={10}
placeholder={placeholder}
placeholder={placeholder || t('toolTextInput.placeholder')}
sx={{
'&.MuiTextField-root': {
backgroundColor: 'background.paper'

View File

@@ -4,6 +4,7 @@ import Typography from '@mui/material/Typography';
import React, { ReactNode } from 'react';
import { FormikProps, FormikValues, useFormikContext } from 'formik';
import ToolOptionGroups, { ToolOptionGroup } from './ToolOptionGroups';
import { useTranslation } from 'react-i18next';
export type UpdateField<T> = <Y extends keyof T>(field: Y, value: T[Y]) => void;
type NonEmptyArray<T> = [T, ...T[]];
@@ -20,6 +21,7 @@ export default function ToolOptions<T extends FormikValues>({
getGroups: GetGroupsType<T> | null;
vertical?: boolean;
}) {
const { t } = useTranslation();
const theme = useTheme();
const formikContext = useFormikContext<T>();
@@ -45,7 +47,7 @@ export default function ToolOptions<T extends FormikValues>({
>
<Stack direction={'row'} spacing={1} alignItems={'center'}>
<SettingsIcon />
<Typography fontSize={22}>Tool options</Typography>
<Typography fontSize={22}>{t('toolOptions.title')}</Typography>
</Stack>
<Box mt={2}>
<Stack direction={'row'} spacing={2}>

View File

@@ -3,13 +3,14 @@ import Button from '@mui/material/Button';
import DownloadIcon from '@mui/icons-material/Download';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import React from 'react';
import { useTranslation } from 'react-i18next';
export default function ResultFooter({
handleDownload,
handleCopy,
disabled,
hideCopy,
downloadLabel = 'Download'
downloadLabel
}: {
handleDownload: () => void;
handleCopy?: () => void;
@@ -17,6 +18,7 @@ export default function ResultFooter({
hideCopy?: boolean;
downloadLabel?: string;
}) {
const { t } = useTranslation();
return (
<Stack mt={1} direction={'row'} spacing={2}>
<Button
@@ -24,7 +26,7 @@ export default function ResultFooter({
onClick={handleDownload}
startIcon={<DownloadIcon />}
>
{downloadLabel}
{downloadLabel || t('resultFooter.download')}
</Button>
{!hideCopy && (
<Button
@@ -32,7 +34,7 @@ export default function ResultFooter({
onClick={handleCopy}
startIcon={<ContentPasteIcon />}
>
Copy to clipboard
{t('resultFooter.copy')}
</Button>
)}
</Stack>

View File

@@ -5,6 +5,7 @@ import greyPattern from '@assets/grey-pattern.png';
import { globalInputHeight } from '../../config/uiConfig';
import ResultFooter from './ResultFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { useTranslation } from 'react-i18next';
export default function ToolFileResult({
title = 'Result',
@@ -19,6 +20,7 @@ export default function ToolFileResult({
loading?: boolean;
loadingText?: string;
}) {
const { t } = useTranslation();
const [preview, setPreview] = React.useState<string | null>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
const theme = useTheme();
@@ -41,9 +43,9 @@ export default function ToolFileResult({
navigator.clipboard
.write([clipboardItem])
.then(() => showSnackBar('File copied', 'success'))
.then(() => showSnackBar(t('toolFileResult.copied'), 'success'))
.catch((err) => {
showSnackBar('Failed to copy: ' + err, 'error');
showSnackBar(t('toolFileResult.copyFailed', { error: err }), 'error');
});
}
};
@@ -91,7 +93,7 @@ export default function ToolFileResult({
return (
<Box>
<InputHeader title={title} />
<InputHeader title={title || t('toolFileResult.result')} />
<Box
sx={{
width: '100%',
@@ -114,7 +116,7 @@ export default function ToolFileResult({
>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }}>
{loadingText}... This may take a moment.
{loadingText || t('toolFileResult.loading')}
</Typography>
</Box>
) : (

View File

@@ -9,8 +9,11 @@ import InputHeader from '../InputHeader';
import greyPattern from '@assets/grey-pattern.png';
import { globalInputHeight } from '../../config/uiConfig';
import ResultFooter from './ResultFooter';
import { useTranslation } from 'react-i18next';
import React, { useContext } from 'react';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
export default function ToolFileResult({
export default function ToolMultiFileResult({
title = 'Result',
value,
zipFile,
@@ -23,7 +26,9 @@ export default function ToolFileResult({
loading?: boolean;
loadingText?: string;
}) {
const { t } = useTranslation();
const theme = useTheme();
const { showSnackBar } = useContext(CustomSnackBarContext);
const getFileType = (
file: File
@@ -46,9 +51,25 @@ export default function ToolFileResult({
URL.revokeObjectURL(url);
};
const handleCopy = () => {
if (zipFile) {
const blob = new Blob([zipFile], { type: zipFile.type });
const clipboardItem = new ClipboardItem({ [zipFile.type]: blob });
navigator.clipboard
.write([clipboardItem])
.then(() => showSnackBar(t('toolMultiFileResult.copied'), 'success'))
.catch((err) => {
showSnackBar(
t('toolMultiFileResult.copyFailed', { error: err }),
'error'
);
});
}
};
return (
<Box>
<InputHeader title={title} />
<InputHeader title={title || t('toolMultiFileResult.result')} />
<Box
sx={{
width: '100%',
@@ -77,7 +98,7 @@ export default function ToolFileResult({
>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }}>
{loadingText}... This may take a moment.
{loadingText || t('toolMultiFileResult.loading')}
</Typography>
</Box>
) : (

View File

@@ -6,6 +6,7 @@ import ResultFooter from './ResultFooter';
import { replaceSpecialCharacters } from '@utils/string';
import mime from 'mime';
import { globalInputHeight } from '../../config/uiConfig';
import { useTranslation } from 'react-i18next';
export default function ToolTextResult({
title = 'Result',
@@ -20,13 +21,14 @@ export default function ToolTextResult({
keepSpecialCharacters?: boolean;
loading?: boolean;
}) {
const { t } = useTranslation();
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleCopy = () => {
navigator.clipboard
.writeText(value)
.then(() => showSnackBar('Text copied', 'success'))
.then(() => showSnackBar(t('toolTextResult.copied'), 'success'))
.catch((err) => {
showSnackBar('Failed to copy: ' + err, 'error');
showSnackBar(t('toolTextResult.copyFailed', { error: err }), 'error');
});
};
const handleDownload = () => {
@@ -48,7 +50,7 @@ export default function ToolTextResult({
};
return (
<Box>
<InputHeader title={title} />
<InputHeader title={title || t('toolTextResult.result')} />
{loading ? (
<Box
sx={{
@@ -61,7 +63,7 @@ export default function ToolTextResult({
>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }}>
Loading... This may take a moment.
{t('toolTextResult.loading')}
</Typography>
</Box>
) : (