feat: dark mode

This commit is contained in:
Ibrahima G. Coulibaly
2025-03-31 01:27:44 +00:00
parent e53642db31
commit d676383d22
26 changed files with 297 additions and 551 deletions

View File

@@ -1,14 +1,14 @@
import { BrowserRouter, useRoutes } from 'react-router-dom';
import routesConfig from '../config/routesConfig';
import Navbar from './Navbar';
import { Suspense } from 'react';
import { Suspense, useMemo, useState } from 'react';
import Loading from './Loading';
import { ThemeProvider } from '@mui/material';
import theme from '../config/muiConfig';
import { CssBaseline, ThemeProvider } from '@mui/material';
import { CustomSnackBarProvider } from '../contexts/CustomSnackBarContext';
import { SnackbarProvider } from 'notistack';
import { tools } from '../tools';
import './index.css';
import { darkTheme, lightTheme } from '../config/muiConfig';
const AppRoutes = () => {
const updatedRoutesConfig = [...routesConfig];
@@ -19,21 +19,29 @@ const AppRoutes = () => {
};
function App() {
const [darkMode, setDarkMode] = useState<boolean>(() => {
return localStorage.getItem('theme') === 'dark';
});
const theme = useMemo(() => (darkMode ? darkTheme : lightTheme), [darkMode]);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<SnackbarProvider
maxSnack={5}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right'
}}
classes={{
containerRoot: 'bottom-0 right-0 mb-52 md:mb-68 mr-8 lg:mr-80 z-99'
}}
>
<CustomSnackBarProvider>
<BrowserRouter>
<Navbar />
<Navbar
onSwitchTheme={() => {
setDarkMode((prevState) => !prevState);
localStorage.setItem('theme', darkMode ? 'light' : 'dark');
}}
/>
<Suspense fallback={<Loading />}>
<AppRoutes />
</Suspense>

View File

@@ -1,4 +1,4 @@
import { Autocomplete, Box, Stack, TextField } from '@mui/material';
import { Autocomplete, Box, Stack, TextField, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import Grid from '@mui/material/Grid';
@@ -25,6 +25,7 @@ const exampleTools: { label: string; url: string }[] = [
];
export default function Hero() {
const [inputValue, setInputValue] = useState<string>('');
const theme = useTheme();
const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(
_.shuffle(tools)
);
@@ -78,7 +79,7 @@ export default function Hero() {
endAdornment: <SearchIcon />,
sx: {
borderRadius: 4,
backgroundColor: 'white'
backgroundColor: 'background.paper'
}
}}
onChange={(event) => handleInputChange(event, event.target.value)}
@@ -125,11 +126,13 @@ export default function Hero() {
borderWidth: 1,
padding: 1,
borderRadius: 3,
borderColor: 'grey',
borderColor: theme.palette.mode === 'dark' ? '#363b41' : 'grey',
borderStyle: 'solid',
backgroundColor: 'white',
backgroundColor: 'background.paper',
cursor: 'pointer',
'&:hover': { backgroundColor: '#FAFAFD' }
'&:hover': {
backgroundColor: 'background.hover'
}
}}
>
<Typography>{tool.label}</Typography>

View File

@@ -17,8 +17,13 @@ import {
import useMediaQuery from '@mui/material/useMediaQuery';
import { useTheme } from '@mui/material/styles';
import { Icon } from '@iconify/react';
import DarkModeIcon from '@mui/icons-material/DarkMode';
const Navbar: React.FC = () => {
interface NavbarProps {
onSwitchTheme: () => void;
}
const Navbar: React.FC<NavbarProps> = ({ onSwitchTheme }) => {
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -30,7 +35,9 @@ const Navbar: React.FC = () => {
// { label: 'Features', path: '/features' }
// { label: 'About Us', path: '/about-us' }
];
const buttons: ReactNode[] = [
<DarkModeIcon onClick={onSwitchTheme} style={{ cursor: 'pointer' }} />,
<Icon
onClick={() => window.open('https://discord.gg/SDbbn3hT4b', '_blank')}
style={{ cursor: 'pointer' }}
@@ -81,9 +88,10 @@ const Navbar: React.FC = () => {
return (
<AppBar
position="static"
style={{
backgroundColor: '#F5F5FA',
color: 'black'
sx={{
background: 'transparent',
boxShadow: 'none',
color: 'text.primary'
}}
>
<Toolbar

View File

@@ -31,7 +31,7 @@ function ToolLinks() {
<Grid container spacing={2} mt={1}>
<Grid item md={12} lg={6}>
<StyledButton
sx={{ backgroundColor: 'white' }}
sx={{ backgroundColor: 'background.paper' }}
fullWidth
variant="outlined"
onClick={() => scrollToElement('tool')}
@@ -43,6 +43,7 @@ function ToolLinks() {
<StyledButton
fullWidth
variant="outlined"
sx={{ backgroundColor: 'background.paper' }}
onClick={() => scrollToElement('examples')}
>
See Examples

View File

@@ -38,7 +38,7 @@ export default function ToolLayout({
display={'flex'}
flexDirection={'column'}
alignItems={'center'}
sx={{ backgroundColor: '#F5F5FA' }}
sx={{ backgroundColor: 'background.default' }}
>
<Helmet>
<title>{`${title} - Omni Tools`}</title>

View File

@@ -1,4 +1,12 @@
import { Box, Card, CardContent, Link, Stack, Typography } from '@mui/material';
import {
Box,
Card,
CardContent,
Link,
Stack,
Typography,
useTheme
} from '@mui/material';
import { ToolCardProps } from './AllTools';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useNavigate } from 'react-router-dom';
@@ -10,6 +18,7 @@ export default function ToolCard({
link,
icon
}: ToolCardProps) {
const theme = useTheme();
const navigate = useNavigate();
return (
<Card
@@ -17,10 +26,13 @@ export default function ToolCard({
raised
sx={{
borderRadius: 2,
bgcolor: '#5581b5',
borderColor: '#5581b5',
bgcolor: 'background.darkSecondary',
borderColor: 'background.darkSecondary',
color: '#fff',
boxShadow: '6px 6px 12px #b8b9be, -6px -6px 12px #fff',
boxShadow:
theme.palette.mode === 'dark'
? null
: '6px 6px 12px #b8b9be, -6px -6px 12px #fff',
cursor: 'pointer',
height: '100%',
'&:hover': {

View File

@@ -38,14 +38,16 @@ export default function ExampleCard<T>({
changeInputResult(sampleText, sampleOptions);
}}
sx={{
bgcolor: theme.palette.background.default,
bgcolor: 'background.lightSecondary',
height: '100%',
overflow: 'hidden',
borderRadius: 2,
transition: 'background-color 0.3s ease',
cursor: 'pointer',
'&:hover': {
boxShadow: '12px 9px 11px 2px #b8b9be, -6px -6px 12px #fff'
boxShadow: `12px 9px 11px 2px ${
theme.palette.mode === 'dark' ? theme.palette.grey[900] : '#b8b9be'
}, -6px -6px 12px ${theme.palette.mode === 'dark' ? 'black' : '#fff'}`
}
}}
>

View File

@@ -94,7 +94,7 @@ export default function BaseFileInput({
border: preview ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'white',
bgcolor: 'background.paper',
position: 'relative'
}}
>
@@ -106,7 +106,8 @@ export default function BaseFileInput({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${greyPattern})`,
backgroundImage:
theme.palette.mode === 'dark' ? null : `url(${greyPattern})`,
position: 'relative',
overflow: 'hidden'
}}
@@ -126,7 +127,13 @@ export default function BaseFileInput({
cursor: 'pointer'
}}
>
<Typography color={theme.palette.grey['600']}>
<Typography
color={
theme.palette.mode === 'dark'
? theme.palette.grey['300']
: theme.palette.grey['600']
}
>
Click here to select a {type} from your device, press Ctrl+V to
use a {type} from your clipboard, drag and drop a file from
desktop

View File

@@ -1,377 +0,0 @@
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import React, { useContext, useEffect, useRef, useState } from 'react';
import ReactCrop, { Crop, PixelCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import greyPattern from '@assets/grey-pattern.png';
import { globalInputHeight } from '../../config/uiConfig';
import Slider from 'rc-slider';
import 'rc-slider/assets/index.css';
interface ToolFileInputProps {
value: File | null;
onChange: (file: File) => void;
accept: string[];
title?: string;
showCropOverlay?: boolean;
cropShape?: 'rectangular' | 'circular';
cropPosition?: { x: number; y: number };
cropSize?: { width: number; height: number };
onCropChange?: (
position: { x: number; y: number },
size: { width: number; height: number }
) => void;
type?: 'image' | 'video' | 'audio';
// Video specific props
showTrimControls?: boolean;
onTrimChange?: (trimStart: number, trimEnd: number) => void;
trimStart?: number;
trimEnd?: number;
}
export default function ToolFileInput({
value,
onChange,
accept,
title = 'File',
showCropOverlay = false,
cropShape = 'rectangular',
cropPosition = { x: 0, y: 0 },
cropSize = { width: 100, height: 100 },
onCropChange,
type = 'image',
showTrimControls = false,
onTrimChange,
trimStart = 0,
trimEnd = 100
}: ToolFileInputProps) {
const [preview, setPreview] = useState<string | null>(null);
const theme = useTheme();
const { showSnackBar } = useContext(CustomSnackBarContext);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [imgWidth, setImgWidth] = useState(0);
const [imgHeight, setImgHeight] = useState(0);
const [videoDuration, setVideoDuration] = useState(0);
// Convert position and size to crop format used by ReactCrop
const [crop, setCrop] = useState<Crop>({
unit: 'px',
x: 0,
y: 0,
width: 0,
height: 0
});
const RATIO = imageRef.current ? imgWidth / imageRef.current.width : 1;
useEffect(() => {
if (imgWidth && imgHeight) {
setCrop({
unit: 'px',
x: cropPosition.x / RATIO,
y: cropPosition.y / RATIO,
width: cropSize.width / RATIO,
height: cropSize.height / RATIO
});
}
}, [cropPosition, cropSize, imgWidth, imgHeight]);
const handleCopy = () => {
if (value) {
const blob = new Blob([value], { type: value.type });
const clipboardItem = new ClipboardItem({ [value.type]: blob });
navigator.clipboard
.write([clipboardItem])
.then(() => showSnackBar('File copied', 'success'))
.catch((err) => {
showSnackBar('Failed to copy: ' + err, 'error');
});
}
};
useEffect(() => {
if (value) {
const objectUrl = URL.createObjectURL(value);
setPreview(objectUrl);
// Clean up memory when the component is unmounted or the file changes
return () => URL.revokeObjectURL(objectUrl);
} else {
setPreview(null);
setImgWidth(0);
setImgHeight(0);
}
}, [value]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onChange(file);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
// Handle image load to set dimensions
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
setImgWidth(width);
setImgHeight(height);
// Initialize crop with a centered default crop if needed
if (!crop.width && !crop.height && onCropChange) {
const initialCrop: Crop = {
unit: 'px',
x: Math.floor(width / 4),
y: Math.floor(height / 4),
width: Math.floor(width / 2),
height: Math.floor(height / 2)
};
setCrop(initialCrop);
// Notify parent component of initial crop
onCropChange(
{ x: initialCrop.x, y: initialCrop.y },
{ width: initialCrop.width, height: initialCrop.height }
);
}
};
// Handle video load to set duration
const onVideoLoad = (e: React.SyntheticEvent<HTMLVideoElement>) => {
const duration = e.currentTarget.duration;
setVideoDuration(duration);
// Initialize trim with full duration if needed
if (onTrimChange && trimStart === 0 && trimEnd === 100) {
onTrimChange(0, duration);
}
};
const handleCropChange = (newCrop: Crop) => {
setCrop(newCrop);
};
const handleCropComplete = (crop: PixelCrop) => {
if (onCropChange) {
onCropChange(
{ x: Math.round(crop.x * RATIO), y: Math.round(crop.y * RATIO) },
{
width: Math.round(crop.width * RATIO),
height: Math.round(crop.height * RATIO)
}
);
}
};
const handleTrimChange = (start: number, end: number) => {
if (onTrimChange) {
onTrimChange(start, end);
}
};
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const clipboardItems = event.clipboardData?.items ?? [];
const item = clipboardItems[0];
if (
item &&
(item.type.includes('image') || item.type.includes('video'))
) {
const file = item.getAsFile();
if (file) onChange(file);
}
};
window.addEventListener('paste', handlePaste);
return () => {
window.removeEventListener('paste', handlePaste);
};
}, [onChange]);
// Format seconds to MM:SS format
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
.toString()
.padStart(2, '0')}`;
};
return (
<Box>
<InputHeader title={title} />
<Box
sx={{
width: '100%',
height: globalInputHeight,
border: preview ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'white',
position: 'relative'
}}
>
{preview ? (
<Box
width="100%"
height="100%"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${greyPattern})`,
position: 'relative',
overflow: 'hidden'
}}
>
{type === 'image' &&
(showCropOverlay ? (
<ReactCrop
crop={crop}
onChange={handleCropChange}
onComplete={handleCropComplete}
circularCrop={cropShape === 'circular'}
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
>
<img
ref={imageRef}
src={preview}
alt="Preview"
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
onLoad={onImageLoad}
/>
</ReactCrop>
) : (
<img
ref={imageRef}
src={preview}
alt="Preview"
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
onLoad={onImageLoad}
/>
))}
{type === 'video' && (
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}
>
<video
ref={videoRef}
src={preview}
style={{
maxWidth: '100%',
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%'
}}
onLoadedMetadata={onVideoLoad}
controls={!showTrimControls}
/>
{showTrimControls && videoDuration > 0 && (
<Box
sx={{
width: '100%',
padding: '10px 20px',
position: 'absolute',
bottom: 0,
left: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
color: 'white',
display: 'flex',
flexDirection: 'column',
gap: 1
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Typography variant="caption">
Start: {formatTime(trimStart || 0)}
</Typography>
<Typography variant="caption">
End: {formatTime(trimEnd || videoDuration)}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<div
className="range-slider-container"
style={{ margin: '20px 0', width: '100%' }}
>
<Slider
range
min={0}
max={videoDuration}
step={0.1}
value={[trimStart || 0, trimEnd || videoDuration]}
onChange={(values) => {
if (Array.isArray(values)) {
handleTrimChange(values[0], values[1]);
}
}}
allowCross={false}
pushable={0.1} // Minimum distance between handles
/>
</div>
</Box>
</Box>
)}
</Box>
)}
{type === 'audio' && (
<audio
src={preview}
controls
style={{ width: '100%', maxWidth: '500px' }}
/>
)}
</Box>
) : (
<Box
onClick={handleImportClick}
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: 5,
height: '100%',
cursor: 'pointer'
}}
>
<Typography color={theme.palette.grey['600']}>
Click here to select a {type} from your device, press Ctrl+V to
use a {type} from your clipboard, drag and drop a file from
desktop
</Typography>
</Box>
)}
</Box>
<InputFooter handleCopy={handleCopy} handleImport={handleImportClick} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
/>
</Box>
);
}

View File

@@ -52,7 +52,7 @@ export default function ToolTextInput({
rows={10}
sx={{
'&.MuiTextField-root': {
backgroundColor: 'white'
backgroundColor: 'background.paper'
}
}}
inputProps={{

View File

@@ -30,7 +30,7 @@ const ColorSelector: React.FC<ColorSelectorProps & TextFieldProps> = ({
<Box mb={1}>
<Stack direction={'row'}>
<TextField
sx={{ backgroundColor: 'white' }}
sx={{ backgroundColor: 'background.paper' }}
value={color}
onChange={handleColorChange}
{...props}

View File

@@ -19,7 +19,7 @@ const TextFieldWithDesc = ({
<Box>
<TextField
placeholder={placeholder}
sx={{ backgroundColor: 'white' }}
sx={{ backgroundColor: 'background.paper' }}
value={value}
onChange={(event) => onOwnChange(event.target.value)}
{...props}

View File

@@ -36,7 +36,7 @@ export default function ToolOptions<T extends FormikValues>({
mb: 2,
borderRadius: 2,
padding: 2,
backgroundColor: theme.palette.background.default,
backgroundColor: 'background.lightSecondary',
boxShadow: '2'
}}
mt={2}

View File

@@ -1,4 +1,4 @@
import { Box, CircularProgress, Typography } from '@mui/material';
import { Box, CircularProgress, Typography, useTheme } from '@mui/material';
import React, { useContext } from 'react';
import InputHeader from '../InputHeader';
import greyPattern from '@assets/grey-pattern.png';
@@ -21,6 +21,7 @@ export default function ToolFileResult({
}) {
const [preview, setPreview] = React.useState<string | null>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
const theme = useTheme();
React.useEffect(() => {
if (value) {
@@ -87,7 +88,7 @@ export default function ToolFileResult({
border: preview ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'white'
bgcolor: 'background.paper'
}}
>
{loading ? (
@@ -114,7 +115,8 @@ export default function ToolFileResult({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundImage: `url(${greyPattern})`
backgroundImage:
theme.palette.mode === 'dark' ? null : `url(${greyPattern})`
}}
>
{fileType === 'image' && (

View File

@@ -52,7 +52,7 @@ export default function ToolTextResult({
multiline
sx={{
'&.MuiTextField-root': {
backgroundColor: 'white'
backgroundColor: 'background.paper'
}
}}
rows={10}