This commit is contained in:
Ibrahima G. Coulibaly
2025-07-07 02:47:16 +01:00
21 changed files with 1411 additions and 193 deletions

View File

@@ -9,6 +9,7 @@ import { SnackbarProvider } from 'notistack';
import { tools } from '../tools';
import './index.css';
import { darkTheme, lightTheme } from '../config/muiConfig';
import ScrollToTopButton from './ScrollToTopButton';
const AppRoutes = () => {
const updatedRoutesConfig = [...routesConfig];
@@ -48,6 +49,7 @@ function App() {
</BrowserRouter>
</CustomSnackBarProvider>
</SnackbarProvider>
<ScrollToTopButton />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react';
import Button from '@mui/material/Button';
import { Icon } from '@iconify/react';
export default function ScrollToTopButton() {
const [visible, setVisible] = useState(false);
useEffect(() => {
const toggleVisibility = () => {
setVisible(window.scrollY > 100);
};
window.addEventListener('scroll', toggleVisibility);
return () => window.removeEventListener('scroll', toggleVisibility);
}, []);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (!visible) return null;
return (
<Button
onClick={scrollToTop}
variant="contained"
color="primary"
sx={{
position: 'fixed',
bottom: 20,
right: 20,
zIndex: 9999,
minWidth: '40px',
width: '40px',
height: '40px',
borderRadius: '50%',
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
aria-label="Scroll to top"
>
<Icon icon="mdi:arrow-up" fontSize={24} style={{ color: 'white' }} />
</Button>
);
}

View File

@@ -8,12 +8,14 @@ export default function ResultFooter({
handleDownload,
handleCopy,
disabled,
hideCopy
hideCopy,
downloadLabel = 'Download'
}: {
handleDownload: () => void;
handleCopy: () => void;
handleCopy?: () => void;
disabled?: boolean;
hideCopy?: boolean;
downloadLabel?: string;
}) {
return (
<Stack mt={1} direction={'row'} spacing={2}>
@@ -22,7 +24,7 @@ export default function ResultFooter({
onClick={handleDownload}
startIcon={<DownloadIcon />}
>
Save as
{downloadLabel}
</Button>
{!hideCopy && (
<Button

View File

@@ -0,0 +1,145 @@
import {
Box,
CircularProgress,
Typography,
useTheme,
Button
} from '@mui/material';
import InputHeader from '../InputHeader';
import greyPattern from '@assets/grey-pattern.png';
import { globalInputHeight } from '../../config/uiConfig';
import ResultFooter from './ResultFooter';
export default function ToolFileResult({
title = 'Result',
value,
zipFile,
loading,
loadingText
}: {
title?: string;
value: File[];
zipFile?: File | null;
loading?: boolean;
loadingText?: string;
}) {
const theme = useTheme();
const getFileType = (
file: File
): 'image' | 'video' | 'audio' | 'pdf' | 'unknown' => {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
if (file.type.startsWith('audio/')) return 'audio';
if (file.type.startsWith('application/pdf')) return 'pdf';
return 'unknown';
};
const handleDownload = (file: File) => {
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
return (
<Box>
<InputHeader title={title} />
<Box
sx={{
width: '100%',
height: globalInputHeight,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 2,
border: 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'background.paper',
alignItems: 'center',
p: 2
}}
>
{loading ? (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: globalInputHeight
}}
>
<CircularProgress />
<Typography variant="body2" sx={{ mt: 2 }}>
{loadingText}... This may take a moment.
</Typography>
</Box>
) : (
value.length > 0 &&
value.map((file, idx) => {
const preview = URL.createObjectURL(file);
const fileType = getFileType(file);
return (
<Box
key={idx}
sx={{
backgroundImage:
fileType === 'image' && theme.palette.mode !== 'dark'
? `url(${greyPattern})`
: 'none',
p: 1,
border: '1px solid #ddd',
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
{fileType === 'image' && (
<img
src={preview}
alt={`Preview ${idx}`}
style={{ maxWidth: '100%', maxHeight: 300 }}
/>
)}
{fileType === 'video' && (
<video src={preview} controls style={{ maxWidth: '100%' }} />
)}
{fileType === 'audio' && (
<audio src={preview} controls style={{ width: '100%' }} />
)}
{fileType === 'pdf' && (
<iframe src={preview} width="100%" height="400px" />
)}
{fileType === 'unknown' && (
<Typography>File ready. Click below to download.</Typography>
)}
<Button
onClick={() => handleDownload(file)}
size="small"
sx={{ mt: 1 }}
variant="contained"
>
Download {file.name}
</Button>
</Box>
);
})
)}
</Box>
<ResultFooter
downloadLabel={'Download All as ZIP'}
hideCopy
disabled={!zipFile}
handleDownload={() => zipFile && handleDownload(zipFile)}
/>
</Box>
);
}

View File

@@ -7,7 +7,7 @@ import { tool as changeOpacity } from './change-opacity/meta';
import { tool as createTransparent } from './create-transparent/meta';
import { tool as imageToText } from './image-to-text/meta';
import { tool as qrCodeGenerator } from './qr-code/meta';
import { tool as rotateImage } from './rotate/meta';
export const imageGenericTools = [
resizeImage,
compressImage,
@@ -17,5 +17,6 @@ export const imageGenericTools = [
changeColors,
createTransparent,
imageToText,
qrCodeGenerator
qrCodeGenerator,
rotateImage
];

View File

@@ -0,0 +1,136 @@
import { ToolComponentProps } from '@tools/defineTool';
import { InitialValuesType } from './type';
import * as Yup from 'yup';
import { useState } from 'react';
import { GetGroupsType } from '@components/options/ToolOptions';
import SimpleRadio from '@components/options/SimpleRadio';
import { Box } from '@mui/material';
import SelectWithDesc from '@components/options/SelectWithDesc';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import ToolContent from '@components/ToolContent';
import ToolImageInput from '@components/input/ToolImageInput';
import ToolFileResult from '@components/result/ToolFileResult';
import { processImage } from './service';
const initialValues: InitialValuesType = {
rotateAngle: '90',
rotateMethod: 'Preset'
};
const validationSchema = Yup.object({
rotateAngle: Yup.number().when('rotateMethod', {
is: 'degrees',
then: (schema) =>
schema
.min(-360, 'Rotate angle must be at least -360')
.max(360, 'Rotate angle must be at most 360')
.required('Rotate angle is required')
})
});
export default function RotateImage({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const compute = async (optionsValues: InitialValuesType, input: any) => {
if (!input) return;
setResult(await processImage(input, optionsValues));
};
const getGroups: GetGroupsType<InitialValuesType> = ({
values,
updateField
}) => [
{
title: 'Rotate Method',
component: (
<Box>
<SimpleRadio
onClick={() => updateField('rotateMethod', 'Preset')}
checked={values.rotateMethod === 'Preset'}
description={'Rotate by a specific angle in degrees.'}
title={'Preset angle'}
/>
<SimpleRadio
onClick={() => updateField('rotateMethod', 'Custom')}
checked={values.rotateMethod === 'Custom'}
description={'Rotate by a custom angle in degrees.'}
title={'Custom angle'}
/>
</Box>
)
},
...(values.rotateMethod === 'Preset'
? [
{
title: 'Preset angle',
component: (
<Box>
<SelectWithDesc
selected={values.rotateAngle}
onChange={(val) => updateField('rotateAngle', val)}
description={'Rotate by a specific angle in degrees.'}
options={[
{ label: '90 degrees', value: '90' },
{ label: '180 degrees', value: '180' },
{ label: '270 degrees', value: '270' }
]}
/>
</Box>
)
}
]
: [
{
title: 'Custom angle',
component: (
<Box>
<TextFieldWithDesc
value={values.rotateAngle}
onOwnChange={(val) => updateField('rotateAngle', val)}
description={
'Rotate by a custom angle in degrees(from -360 to 360).'
}
inputProps={{
type: 'number',
min: -360,
max: 360
}}
/>
</Box>
)
}
])
];
return (
<ToolContent
title={title}
initialValues={initialValues}
getGroups={getGroups}
compute={compute}
input={input}
validationSchema={validationSchema}
inputComponent={
<ToolImageInput
value={input}
onChange={setInput}
title={'Input Image'}
accept={['image/*']}
/>
}
resultComponent={
<ToolFileResult
value={result}
title={'Rotated Image'}
extension={input?.name.split('.').pop() || 'png'}
/>
}
toolInfo={{
title: 'Rotate Image',
description:
'This tool allows you to rotate images by a specific angle in any degrees.'
}}
/>
);
}

View File

@@ -0,0 +1,12 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('image-generic', {
name: 'Rotate Image',
path: 'rotate',
icon: 'mdi:rotate-clockwise',
description: 'Rotate an image by a specified angle.',
shortDescription: 'Rotate an image easily.',
keywords: ['rotate', 'image', 'angle', 'jpg', 'png'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,74 @@
import { InitialValuesType } from './type';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
export const processImage = async (
file: File,
options: InitialValuesType
): Promise<File | null> => {
const { rotateAngle, rotateMethod } = options;
if (file.type === 'image/svg+xml') {
try {
// Read the SVG file
const fileText = await file.text();
const parser = new DOMParser();
const svgDoc = parser.parseFromString(fileText, 'image/svg+xml');
const svgElement = svgDoc.documentElement as unknown as SVGSVGElement;
// Get current transform attribute or create new one
let currentTransform = svgElement.getAttribute('transform') || '';
const angle = parseInt(rotateAngle);
// Add rotation if needed
if (angle !== 0) {
// Get SVG dimensions
const bbox = svgElement.getBBox();
const centerX = bbox.x + bbox.width / 2;
const centerY = bbox.y + bbox.height / 2;
currentTransform += ` rotate(${angle} ${centerX} ${centerY})`;
}
// Apply transform
svgElement.setAttribute('transform', currentTransform.trim());
// Convert back to file
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgDoc);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
return new File([blob], file.name, { type: 'image/svg+xml' });
} catch (error) {
console.error('Error processing SVG:', error);
return null;
}
}
// For non-SVG images, use FFmpeg
try {
const ffmpeg = new FFmpeg();
await ffmpeg.load();
// Write input file
await ffmpeg.writeFile('input', await fetchFile(file));
// Determine rotation command
const rotateCmd = `rotate=${rotateAngle}*PI/180`;
// Execute FFmpeg command
await ffmpeg.exec([
'-i',
'input',
'-vf',
rotateCmd,
'output.' + file.name.split('.').pop()
]);
// Read the output file
const data = await ffmpeg.readFile('output.' + file.name.split('.').pop());
return new File([data], file.name, { type: file.type });
} catch (error) {
console.error('Error processing image:', error);
return null;
}
};

View File

@@ -0,0 +1,4 @@
export type InitialValuesType = {
rotateAngle: string; // the angle to rotate the image
rotateMethod: 'Preset' | 'Custom';
};

View File

@@ -1,3 +1,4 @@
import { tool as pdfPdfToPng } from './pdf-to-png/meta';
import { tool as pdfRotatePdf } from './rotate-pdf/meta';
import { meta as splitPdfMeta } from './split-pdf/meta';
import { meta as mergePdf } from './merge-pdf/meta';
@@ -12,5 +13,6 @@ export const pdfTools: DefinedTool[] = [
compressPdfTool,
protectPdfTool,
mergePdf,
pdfToEpub
pdfToEpub,
pdfPdfToPng
];

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import ToolContent from '@components/ToolContent';
import ToolPdfInput from '@components/input/ToolPdfInput';
import { ToolComponentProps } from '@tools/defineTool';
import { convertPdfToPngImages } from './service';
import ToolMultiFileResult from '@components/result/ToolMultiFileResult';
type ImagePreview = {
blob: Blob;
url: string;
filename: string;
};
export default function PdfToPng({ title }: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [images, setImages] = useState<ImagePreview[]>([]);
const [zipBlob, setZipBlob] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = async (_: {}, file: File | null) => {
if (!file) return;
setLoading(true);
setImages([]);
setZipBlob(null);
try {
const { images, zipFile } = await convertPdfToPngImages(file);
setImages(images);
setZipBlob(zipFile);
} catch (err) {
console.error('Conversion failed:', err);
} finally {
setLoading(false);
}
};
return (
<ToolContent
title={title}
input={input}
setInput={setInput}
initialValues={{}}
compute={compute}
inputComponent={
<ToolPdfInput
value={input}
onChange={setInput}
accept={['application/pdf']}
title="Upload a PDF"
/>
}
resultComponent={
<ToolMultiFileResult
title="Converted PNG Pages"
value={images.map((img) => {
return new File([img.blob], img.filename, { type: 'image/png' });
})}
zipFile={zipBlob}
loading={loading}
loadingText="Converting PDF pages"
/>
}
getGroups={null}
toolInfo={{
title: 'Convert PDF pages into PNG images',
description:
'Upload your PDF and get each page rendered as a high-quality PNG. You can preview, download individually, or get all images in a ZIP.'
}}
/>
);
}

View File

@@ -0,0 +1,14 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('pdf', {
name: 'PDF to PNG',
path: 'pdf-to-png',
icon: 'mdi:image-multiple', // Iconify icon ID
description: 'Transform PDF documents into PNG panels.',
shortDescription: 'Convert PDF into PNG images',
keywords: ['pdf', 'png', 'convert', 'image', 'extract', 'pages'],
longDescription:
'Upload a PDF and convert each page into a high-quality PNG image directly in your browser. This tool is ideal for extracting visual content or sharing individual pages. No data is uploaded — everything runs locally.',
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,51 @@
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min?url';
import JSZip from 'jszip';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
type ImagePreview = {
blob: Blob;
url: string;
filename: string;
};
export async function convertPdfToPngImages(pdfFile: File): Promise<{
images: ImagePreview[];
zipFile: File;
}> {
const arrayBuffer = await pdfFile.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const zip = new JSZip();
const images: ImagePreview[] = [];
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 2 });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: context, viewport }).promise;
const blob = await new Promise<Blob>((resolve) =>
canvas.toBlob((b) => b && resolve(b), 'image/png')
);
const filename = `page-${i}.png`;
const url = URL.createObjectURL(blob);
images.push({ blob, url, filename });
zip.file(filename, blob);
}
const zipBuffer = await zip.generateAsync({ type: 'arraybuffer' });
const zipFile = new File(
[zipBuffer],
pdfFile.name.replace(/\.pdf$/i, '-pages.zip'),
{ type: 'application/zip' }
);
return { images, zipFile };
}

View File

@@ -1,14 +1,12 @@
import { tool as videoChangeSpeed } from './change-speed/meta';
import { tool as videoFlip } from './flip/meta';
import { rotate } from '../string/rotate/service';
import { tool as videoToGif } from './video-to-gif/meta';
import { tool as changeSpeed } from './change-speed/meta';
import { tool as flipVideo } from './flip/meta';
import { gifTools } from './gif';
import { tool as trimVideo } from './trim/meta';
import { tool as rotateVideo } from './rotate/meta';
import { tool as compressVideo } from './compress/meta';
import { tool as loopVideo } from './loop/meta';
import { tool as flipVideo } from './flip/meta';
import { tool as cropVideo } from './crop-video/meta';
import { tool as changeSpeed } from './change-speed/meta';
export const videoTools = [
...gifTools,
@@ -18,5 +16,6 @@ export const videoTools = [
loopVideo,
flipVideo,
cropVideo,
changeSpeed
changeSpeed,
videoToGif
];

View File

@@ -0,0 +1,176 @@
import { Box } from '@mui/material';
import React, { useState } from 'react';
import ToolContent from '@components/ToolContent';
import { ToolComponentProps } from '@tools/defineTool';
import { GetGroupsType } from '@components/options/ToolOptions';
import { InitialValuesType } from './types';
import ToolVideoInput from '@components/input/ToolVideoInput';
import ToolFileResult from '@components/result/ToolFileResult';
import SimpleRadio from '@components/options/SimpleRadio';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
const initialValues: InitialValuesType = {
quality: 'mid',
fps: '10',
scale: '320:-1:flags=bicubic'
};
export default function VideoToGif({
title,
longDescription
}: ToolComponentProps) {
const [input, setInput] = useState<File | null>(null);
const [result, setResult] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const compute = (values: InitialValuesType, input: File | null) => {
if (!input) return;
const { fps, scale } = values;
let ffmpeg: FFmpeg | null = null;
let ffmpegLoaded = false;
const convertVideoToGif = async (
file: File,
fps: string,
scale: string
): Promise<void> => {
setLoading(true);
if (!ffmpeg) {
ffmpeg = new FFmpeg();
}
if (!ffmpegLoaded) {
await ffmpeg.load({
wasmURL:
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
});
ffmpegLoaded = true;
}
const fileName = file.name;
const outputName = 'output.gif';
try {
ffmpeg.writeFile(fileName, await fetchFile(file));
await ffmpeg.exec([
'-i',
fileName,
'-vf',
`fps=${fps},scale=${scale},palettegen`,
'palette.png'
]);
await ffmpeg.exec([
'-i',
fileName,
'-i',
'palette.png',
'-filter_complex',
`fps=${fps},scale=${scale}[x];[x][1:v]paletteuse`,
outputName
]);
const data = await ffmpeg.readFile(outputName);
const blob = new Blob([data], { type: 'image/gif' });
const convertedFile = new File([blob], outputName, {
type: 'image/gif'
});
await ffmpeg.deleteFile(fileName);
await ffmpeg.deleteFile(outputName);
setResult(convertedFile);
} catch (err) {
console.error(`Failed to convert video: ${err}`);
throw err;
} finally {
setLoading(false);
}
};
convertVideoToGif(input, fps, scale);
};
const getGroups: GetGroupsType<InitialValuesType> | null = ({
values,
updateField
}) => [
{
title: 'Set Quality',
component: (
<Box>
<SimpleRadio
title="Low"
onClick={() => {
updateField('quality', 'low');
updateField('fps', '5');
updateField('scale', '240:-1:flags=bilinear');
}}
checked={values.quality === 'low'}
/>
<SimpleRadio
title="Mid"
onClick={() => {
updateField('quality', 'mid');
updateField('fps', '10');
updateField('scale', '320:-1:flags=bicubic');
}}
checked={values.quality === 'mid'}
/>
<SimpleRadio
title="High"
onClick={() => {
updateField('quality', 'high');
updateField('fps', '15');
updateField('scale', '480:-1:flags=lanczos');
}}
checked={values.quality === 'high'}
/>
<SimpleRadio
title="Ultra"
onClick={() => {
updateField('quality', 'ultra');
updateField('fps', '15');
updateField('scale', '640:-1:flags=lanczos');
}}
checked={values.quality === 'ultra'}
/>
</Box>
)
}
];
return (
<ToolContent
title={title}
input={input}
inputComponent={
<ToolVideoInput value={input} onChange={setInput} title="Input Video" />
}
resultComponent={
loading ? (
<ToolFileResult
title="Converting to Gif"
value={null}
loading={true}
/>
) : (
<ToolFileResult
title="Converted to Gif"
value={result}
extension="gif"
/>
)
}
initialValues={initialValues}
getGroups={getGroups}
setInput={setInput}
compute={compute}
toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
/>
);
}

View File

@@ -0,0 +1,12 @@
import { defineTool } from '@tools/defineTool';
import { lazy } from 'react';
export const tool = defineTool('video', {
name: 'Video to Gif',
path: 'video-to-gif',
icon: 'fluent:gif-16-regular',
description: 'This online utility lets you convert a short video to gif.',
shortDescription: 'Quickly convert a short video to gif',
keywords: ['video', 'to', 'gif', 'convert'],
component: lazy(() => import('./index'))
});

View File

@@ -0,0 +1,5 @@
export type InitialValuesType = {
quality: 'mid' | 'high' | 'low' | 'ultra';
fps: string;
scale: string;
};