mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-12-29 16:16:02 +00:00
Merge branch 'main' of https://github.com/iib0011/omni-tools into fork/AshAnand34/merge-video-tool
This commit is contained in:
@@ -10,6 +10,8 @@ import { tools } from '../tools';
|
||||
import './index.css';
|
||||
import { darkTheme, lightTheme } from '../config/muiConfig';
|
||||
import ScrollToTopButton from './ScrollToTopButton';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export type Mode = 'dark' | 'light' | 'system';
|
||||
|
||||
@@ -44,32 +46,34 @@ function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider
|
||||
maxSnack={5}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
>
|
||||
<CustomSnackBarProvider>
|
||||
<BrowserRouter>
|
||||
<Navbar
|
||||
mode={mode}
|
||||
onChangeMode={() => {
|
||||
setMode((prev) => nextMode(prev));
|
||||
localStorage.setItem('theme', nextMode(mode));
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</CustomSnackBarProvider>
|
||||
</SnackbarProvider>
|
||||
<ScrollToTopButton />
|
||||
</ThemeProvider>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider
|
||||
maxSnack={5}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
>
|
||||
<CustomSnackBarProvider>
|
||||
<BrowserRouter>
|
||||
<Navbar
|
||||
mode={mode}
|
||||
onChangeMode={() => {
|
||||
setMode((prev) => nextMode(prev));
|
||||
localStorage.setItem('theme', nextMode(mode));
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</CustomSnackBarProvider>
|
||||
</SnackbarProvider>
|
||||
<ScrollToTopButton />
|
||||
</ThemeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,16 @@ import { useState } from 'react';
|
||||
import { DefinedTool } from '@tools/defineTool';
|
||||
import { filterTools, tools } from '@tools/index';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import _ from 'lodash';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { getToolCategoryTitle } from '@utils/string';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FullI18nKey, validNamespaces } from '../i18n';
|
||||
import {
|
||||
getBookmarkedToolPaths,
|
||||
isBookmarked,
|
||||
toggleBookmarked
|
||||
} from '@utils/bookmark';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
|
||||
const GroupHeader = styled('div')(({ theme }) => ({
|
||||
position: 'sticky',
|
||||
@@ -33,44 +40,98 @@ const GroupHeader = styled('div')(({ theme }) => ({
|
||||
const GroupItems = styled('ul')({
|
||||
padding: 0
|
||||
});
|
||||
const exampleTools: { label: string; url: string }[] = [
|
||||
{
|
||||
label: 'Create a transparent image',
|
||||
url: '/image-generic/create-transparent'
|
||||
},
|
||||
{ label: 'Prettify JSON', url: '/json/prettify' },
|
||||
{ label: 'Change GIF speed', url: '/gif/change-speed' },
|
||||
{ label: 'Sort a list', url: '/list/sort' },
|
||||
{ label: 'Compress PNG', url: '/png/compress-png' },
|
||||
{ label: 'Split a text', url: '/string/split' },
|
||||
{ label: 'Split PDF', url: '/pdf/split-pdf' },
|
||||
{ label: 'Trim video', url: '/video/trim' },
|
||||
{ label: 'Calculate number sum', url: '/number/sum' }
|
||||
];
|
||||
|
||||
type ToolInfo = {
|
||||
label: FullI18nKey;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export default function Hero() {
|
||||
const { t } = useTranslation(validNamespaces);
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const theme = useTheme();
|
||||
const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(tools);
|
||||
const [bookmarkedToolPaths, setBookmarkedToolPaths] = useState<string[]>(
|
||||
getBookmarkedToolPaths()
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const exampleTools: ToolInfo[] = [
|
||||
{
|
||||
label: 'translation:hero.examples.createTransparentImage',
|
||||
url: '/image-generic/create-transparent'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.prettifyJson',
|
||||
url: '/json/prettify'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.changeGifSpeed',
|
||||
url: '/gif/change-speed'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.sortList',
|
||||
url: '/list/sort'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.compressPng',
|
||||
url: '/png/compress-png'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.splitText',
|
||||
url: '/string/split'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.splitPdf',
|
||||
url: '/pdf/split-pdf'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.trimVideo',
|
||||
url: '/video/trim'
|
||||
},
|
||||
{
|
||||
label: 'translation:hero.examples.calculateNumberSum',
|
||||
url: '/number/sum'
|
||||
}
|
||||
];
|
||||
|
||||
const handleInputChange = (
|
||||
event: React.ChangeEvent<{}>,
|
||||
newInputValue: string
|
||||
) => {
|
||||
setInputValue(newInputValue);
|
||||
setFilteredTools(filterTools(tools, newInputValue));
|
||||
setFilteredTools(filterTools(tools, newInputValue, t));
|
||||
};
|
||||
const toolsMap = new Map<string, ToolInfo>();
|
||||
for (const tool of filteredTools) {
|
||||
toolsMap.set(tool.path, {
|
||||
label: tool.name,
|
||||
url: '/' + tool.path
|
||||
});
|
||||
}
|
||||
|
||||
const displayedTools =
|
||||
bookmarkedToolPaths.length > 0
|
||||
? bookmarkedToolPaths.flatMap((path) => {
|
||||
const tool = toolsMap.get(path);
|
||||
if (tool === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [tool];
|
||||
})
|
||||
: exampleTools;
|
||||
|
||||
return (
|
||||
<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('translation:hero.title')}{' '}
|
||||
<Typography
|
||||
fontSize={{ xs: 25, md: 30 }}
|
||||
display={'inline'}
|
||||
color={'primary'}
|
||||
>
|
||||
OmniTools
|
||||
{t('translation:hero.brand')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
@@ -79,9 +140,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('translation:hero.description')}
|
||||
</Typography>
|
||||
|
||||
<Autocomplete
|
||||
@@ -92,18 +151,18 @@ export default function Hero() {
|
||||
renderGroup={(params) => {
|
||||
return (
|
||||
<li key={params.key}>
|
||||
<GroupHeader>{getToolCategoryTitle(params.group)}</GroupHeader>
|
||||
<GroupHeader>{getToolCategoryTitle(params.group, t)}</GroupHeader>
|
||||
<GroupItems>{params.children}</GroupItems>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
inputValue={inputValue}
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionLabel={(option) => t(option.name)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
fullWidth
|
||||
placeholder={'Search all tools'}
|
||||
placeholder={t('translation:hero.searchPlaceholder')}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
endAdornment: <SearchIcon />,
|
||||
@@ -121,12 +180,42 @@ export default function Hero() {
|
||||
{...props}
|
||||
onClick={() => navigate('/' + option.path)}
|
||||
>
|
||||
<Stack direction={'row'} spacing={2} alignItems={'center'}>
|
||||
<Icon fontSize={20} icon={option.icon} />
|
||||
<Box>
|
||||
<Typography fontWeight={'bold'}>{option.name}</Typography>
|
||||
<Typography fontSize={12}>{option.shortDescription}</Typography>
|
||||
</Box>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
width={'100%'}
|
||||
>
|
||||
<Stack direction={'row'} spacing={2} alignItems={'center'}>
|
||||
<Icon fontSize={20} icon={option.icon} />
|
||||
<Box>
|
||||
<Typography fontWeight={'bold'}>{t(option.name)}</Typography>
|
||||
<Typography fontSize={12}>
|
||||
{t(option.shortDescription)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleBookmarked(option.path);
|
||||
setBookmarkedToolPaths(getBookmarkedToolPaths());
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
fontSize={20}
|
||||
color={
|
||||
isBookmarked(option.path)
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.grey[500]
|
||||
}
|
||||
icon={
|
||||
isBookmarked(option.path)
|
||||
? 'mdi:bookmark'
|
||||
: 'mdi:bookmark-plus-outline'
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -137,7 +226,7 @@ export default function Hero() {
|
||||
}}
|
||||
/>
|
||||
<Grid container spacing={2} mt={2}>
|
||||
{exampleTools.map((tool) => (
|
||||
{displayedTools.map((tool) => (
|
||||
<Grid
|
||||
onClick={() =>
|
||||
navigate(tool.url.startsWith('/') ? tool.url : `/${tool.url}`)
|
||||
@@ -162,10 +251,30 @@ export default function Hero() {
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'background.hover'
|
||||
}
|
||||
},
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Typography>{tool.label}</Typography>
|
||||
<Stack direction={'row'} spacing={1} alignItems={'center'}>
|
||||
<Typography textAlign={'center'}>{t(tool.label)}</Typography>
|
||||
{bookmarkedToolPaths.length > 0 && (
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const path = tool.url.substring(1);
|
||||
toggleBookmarked(path);
|
||||
setBookmarkedToolPaths(getBookmarkedToolPaths());
|
||||
}}
|
||||
size={'small'}
|
||||
>
|
||||
<Icon
|
||||
icon={'mdi:close'}
|
||||
color={theme.palette.grey[500]}
|
||||
fontSize={15}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
|
||||
@@ -13,22 +13,39 @@ import {
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
Stack
|
||||
Stack,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl
|
||||
} from '@mui/material';
|
||||
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;
|
||||
onChangeMode: () => void;
|
||||
}
|
||||
const languages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'de', label: 'Deutsch' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'pt', label: 'Português' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'hi', label: 'हिंदी' },
|
||||
{ code: 'nl', label: 'Nederlands' },
|
||||
{ code: 'ru', label: 'Русский' },
|
||||
{ code: 'zh', label: '中文' }
|
||||
];
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({
|
||||
mode,
|
||||
onChangeMode: onChangeMode
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -36,12 +53,51 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||
const toggleDrawer = (open: boolean) => () => {
|
||||
setDrawerOpen(open);
|
||||
};
|
||||
|
||||
const handleLanguageChange = (event: any) => {
|
||||
const newLanguage = event.target.value;
|
||||
i18n.changeLanguage(newLanguage);
|
||||
localStorage.setItem('lang', newLanguage);
|
||||
};
|
||||
|
||||
const navItems: { label: string; path: string }[] = [
|
||||
// { label: 'Features', path: '/features' }
|
||||
// { label: 'About Us', path: '/about-us' }
|
||||
];
|
||||
|
||||
const languageSelector = (
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
displayEmpty
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
'& .MuiSelect-icon': {
|
||||
color: 'inherit'
|
||||
},
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'transparent'
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'transparent'
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'transparent'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<MenuItem key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
const buttons: ReactNode[] = [
|
||||
languageSelector,
|
||||
<Icon
|
||||
key={mode}
|
||||
onClick={onChangeMode}
|
||||
@@ -83,7 +139,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
Buy me a coffee
|
||||
{t('navbar.buyMeACoffee')}
|
||||
</Button>
|
||||
];
|
||||
const drawerList = (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, styled, useTheme } from '@mui/material';
|
||||
import { Box, Button, Stack, styled, useTheme } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ToolBreadcrumb from './ToolBreadcrumb';
|
||||
import { capitalizeFirstLetter } from '../utils/string';
|
||||
@@ -7,6 +7,11 @@ import { Icon, IconifyIcon } from '@iconify/react';
|
||||
import { categoriesColors } from '../config/uiConfig';
|
||||
import { getToolsByCategory } from '@tools/index';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isBookmarked, toggleBookmarked } from '@utils/bookmark';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { validNamespaces } from '../i18n';
|
||||
|
||||
const StyledButton = styled(Button)(({ theme }) => ({
|
||||
backgroundColor: 'white',
|
||||
@@ -21,10 +26,14 @@ interface ToolHeaderProps {
|
||||
description: string;
|
||||
icon?: IconifyIcon | string;
|
||||
type: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function ToolLinks() {
|
||||
const { t } = useTranslation();
|
||||
const [examplesVisible, setExamplesVisible] = useState(false);
|
||||
const theme = useTheme();
|
||||
const isMd = useMediaQuery(theme.breakpoints.down('md'));
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -45,16 +54,18 @@ function ToolLinks() {
|
||||
}
|
||||
return (
|
||||
<Grid container spacing={2} mt={1}>
|
||||
<Grid item md={12} lg={6}>
|
||||
<StyledButton
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => scrollToElement('tool')}
|
||||
>
|
||||
Use This Tool
|
||||
</StyledButton>
|
||||
</Grid>
|
||||
{isMd && (
|
||||
<Grid item md={12} lg={6}>
|
||||
<StyledButton
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => scrollToElement('tool')}
|
||||
>
|
||||
Use This Tool
|
||||
</StyledButton>
|
||||
</Grid>
|
||||
)}
|
||||
{examplesVisible && (
|
||||
<Grid item md={12} lg={6}>
|
||||
<StyledButton
|
||||
@@ -63,7 +74,7 @@ function ToolLinks() {
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
onClick={() => scrollToElement('examples')}
|
||||
>
|
||||
See Examples
|
||||
{t('toolHeader.seeExamples')}
|
||||
</StyledButton>
|
||||
</Grid>
|
||||
)}
|
||||
@@ -80,15 +91,19 @@ export default function ToolHeader({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
type
|
||||
type,
|
||||
path
|
||||
}: ToolHeaderProps) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [bookmarked, setBookmarked] = useState<boolean>(isBookmarked(path));
|
||||
return (
|
||||
<Box my={4}>
|
||||
<ToolBreadcrumb
|
||||
items={[
|
||||
{ title: 'All tools', link: '/' },
|
||||
{
|
||||
title: getToolsByCategory().find(
|
||||
title: getToolsByCategory(t).find(
|
||||
(category) => category.type === type
|
||||
)!.rawTitle,
|
||||
link: '/categories/' + type
|
||||
@@ -98,9 +113,27 @@ export default function ToolHeader({
|
||||
/>
|
||||
<Grid mt={1} container spacing={2}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Typography mb={2} fontSize={30} color={'primary'}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack direction={'row'} spacing={2} alignItems={'center'}>
|
||||
<Typography mb={2} fontSize={30} color={'primary'}>
|
||||
{title}
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
toggleBookmarked(path);
|
||||
setBookmarked(!bookmarked);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
fontSize={30}
|
||||
color={
|
||||
bookmarked
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.grey[500]
|
||||
}
|
||||
icon={bookmarked ? 'mdi:bookmark' : 'mdi:bookmark-plus-outline'}
|
||||
/>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Typography fontSize={20}>{description}</Typography>
|
||||
<ToolLinks />
|
||||
</Grid>
|
||||
|
||||
@@ -5,26 +5,47 @@ import ToolHeader from './ToolHeader';
|
||||
import Separator from './Separator';
|
||||
import AllTools from './allTools/AllTools';
|
||||
import { getToolsByCategory } from '@tools/index';
|
||||
import { capitalizeFirstLetter } from '../utils/string';
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
getI18nNamespaceFromToolCategory
|
||||
} from '../utils/string';
|
||||
import { IconifyIcon } from '@iconify/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolCategory } from '@tools/defineTool';
|
||||
import { FullI18nKey } from '../i18n';
|
||||
|
||||
export default function ToolLayout({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
type
|
||||
i18n,
|
||||
type,
|
||||
fullPath
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: IconifyIcon | string;
|
||||
type: string;
|
||||
type: ToolCategory;
|
||||
fullPath: string;
|
||||
children: ReactNode;
|
||||
i18n?: {
|
||||
name: FullI18nKey;
|
||||
description: FullI18nKey;
|
||||
shortDescription: FullI18nKey;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation([
|
||||
'translation',
|
||||
getI18nNamespaceFromToolCategory(type)
|
||||
]);
|
||||
|
||||
// Use i18n keys if available, otherwise fall back to provided strings
|
||||
//@ts-ignore
|
||||
const toolTitle: string = t(i18n.name);
|
||||
//@ts-ignore
|
||||
const toolDescription: string = t(i18n.description);
|
||||
|
||||
const otherCategoryTools =
|
||||
getToolsByCategory()
|
||||
getToolsByCategory(t)
|
||||
.find((category) => category.type === type)
|
||||
?.tools.filter((tool) => tool.name !== title)
|
||||
?.tools.filter((tool) => t(tool.name) !== toolTitle)
|
||||
.map((tool) => ({
|
||||
title: tool.name,
|
||||
description: tool.shortDescription,
|
||||
@@ -41,22 +62,25 @@ 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}
|
||||
path={fullPath}
|
||||
/>
|
||||
{children}
|
||||
<Separator backgroundColor="#5581b5" margin="50px" />
|
||||
<AllTools
|
||||
title={`All ${capitalizeFirstLetter(
|
||||
getToolsByCategory().find((category) => category.type === type)!
|
||||
.rawTitle
|
||||
)} tools`}
|
||||
title={t('translation:toolLayout.allToolsTitle', '', {
|
||||
type: capitalizeFirstLetter(
|
||||
getToolsByCategory(t).find((category) => category.type === type)!
|
||||
.title
|
||||
)
|
||||
})}
|
||||
toolCards={otherCategoryTools}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Box, Grid, Stack, Typography } from '@mui/material';
|
||||
import ToolCard from './ToolCard';
|
||||
import { IconifyIcon } from '@iconify/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FullI18nKey } from '../../i18n';
|
||||
|
||||
export interface ToolCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
title: FullI18nKey;
|
||||
description: FullI18nKey;
|
||||
link: string;
|
||||
icon: IconifyIcon | string;
|
||||
}
|
||||
@@ -15,6 +17,7 @@ interface AllToolsProps {
|
||||
}
|
||||
|
||||
export default function AllTools({ title, toolCards }: AllToolsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Box mt={4} mb={10}>
|
||||
<Typography mb={2} fontSize={30} color={'primary'}>
|
||||
@@ -25,8 +28,10 @@ export default function AllTools({ title, toolCards }: AllToolsProps) {
|
||||
{toolCards.map((card, index) => (
|
||||
<Grid item xs={12} md={6} lg={4} key={index}>
|
||||
<ToolCard
|
||||
title={card.title}
|
||||
description={card.description}
|
||||
//@ts-ignore
|
||||
title={t(card.title)}
|
||||
//@ts-ignore
|
||||
description={t(card.description)}
|
||||
link={card.link}
|
||||
icon={card.icon}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user