diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 9f82295..dca2541 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,10 +4,11 @@
-
+
+
-
-
+
+
@@ -24,7 +25,7 @@
@@ -43,59 +44,73 @@
"state": "OPEN"
}
}
- {
- "prStates": [
+
+}]]>
{
"selectedUrlAndAccountId": {
"url": "https://github.com/iib0011/omni-tools.git",
@@ -124,55 +139,56 @@
- {
+ "keyToString": {
+ "ASKED_ADD_EXTERNAL_FILES": "true",
+ "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
+ "Docker.Dockerfile build.executor": "Run",
+ "Docker.Dockerfile.executor": "Run",
+ "Playwright.Create transparent PNG.should make png color transparent.executor": "Run",
+ "Playwright.JoinText Component.executor": "Run",
+ "Playwright.JoinText Component.should merge text pieces with specified join character.executor": "Run",
+ "RunOnceActivity.OpenProjectViewOnStart": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "Vitest.compute function (1).executor": "Run",
+ "Vitest.compute function.executor": "Run",
+ "Vitest.mergeText.executor": "Run",
+ "Vitest.mergeText.should merge lines and preserve blank lines when deleteBlankLines is false.executor": "Run",
+ "Vitest.mergeText.should merge lines, preserve blank lines and trailing spaces when both deleteBlankLines and deleteTrailingSpaces are false.executor": "Run",
+ "Vitest.parsePageRanges.executor": "Run",
+ "Vitest.removeDuplicateLines function.executor": "Run",
+ "Vitest.removeDuplicateLines function.newlines option.executor": "Run",
+ "Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run",
+ "Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
+ "Vitest.replaceText function.executor": "Run",
+ "Vitest.timeBetweenDates.executor": "Run",
+ "git-widget-placeholder": "main",
+ "ignore.virus.scanning.warn.message": "true",
+ "kotlin-language-version-configured": "true",
+ "last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src/components/input",
+ "node.js.detected.package.eslint": "true",
+ "node.js.detected.package.tslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "node.js.selected.package.tslint": "(autodetect)",
+ "nodejs_package_manager_path": "npm",
+ "npm.build.executor": "Run",
+ "npm.dev.executor": "Run",
+ "npm.lint.executor": "Run",
+ "npm.prebuild.executor": "Run",
+ "npm.script:create:tool.executor": "Run",
+ "npm.test.executor": "Run",
+ "npm.test:e2e.executor": "Run",
+ "npm.test:e2e:run.executor": "Run",
+ "prettierjs.PrettierConfiguration.Package": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\prettier",
+ "project.structure.last.edited": "Problems",
+ "project.structure.proportion": "0.0",
+ "project.structure.side.proportion": "0.2",
+ "settings.editor.selected.configurable": "refactai_advanced_settings",
+ "ts.external.directory.path": "C:\\Users\\Ibrahima\\IdeaProjects\\omni-tools\\node_modules\\typescript\\lib",
+ "vue.rearranger.settings.migration": "true"
}
-}]]>
+}
-
-
+
+
-
+
+
-
-
+
+
-
-
+
@@ -239,6 +255,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -255,30 +284,20 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
@@ -364,22 +383,7 @@
-
-
-
- 1740504100676
-
-
-
- 1740504100676
-
-
-
- 1740505390205
-
-
-
- 1740505390205
+
@@ -757,7 +761,23 @@
1743051792459
-
+
+
+ 1743052111988
+
+
+
+ 1743052111988
+
+
+
+ 1743106796406
+
+
+
+ 1743106796406
+
+
@@ -804,8 +824,6 @@
-
-
@@ -829,7 +847,9 @@
-
+
+
+
diff --git a/README.md b/README.md
index 4b25635..c515d8b 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
@@ -13,18 +13,16 @@
-
Welcome to OmniTools, a self-hosted web app offering a variety of online tools to simplify everyday tasks.
Whether you are coding, manipulating images or crunching numbers, OmniTools has you covered. Please don't forget to
star the repo to support us.
-Here is the [demo](https://omnitools.netlify.app/) website.
+Here is the [demo](https://omnitools.app) website.

@@ -86,9 +84,7 @@ docker run -d --name omni-tools --restart unless-stopped -p 8080:80 iib0011/omni
### Docker Compose
-```
-version: '3.3'
-
+```yaml
services:
omni-tools:
image: iib0011/omni-tools:latest
diff --git a/img.png b/img.png
index 252a3d5..b6f04cc 100644
Binary files a/img.png and b/img.png differ
diff --git a/src/components/BackButton.tsx b/src/components/BackButton.tsx
new file mode 100644
index 0000000..40b9918
--- /dev/null
+++ b/src/components/BackButton.tsx
@@ -0,0 +1,20 @@
+import { IconButton } from '@mui/material';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
+import { useNavigate, useNavigationType } from 'react-router-dom';
+
+const BackButton = () => {
+ const navigate = useNavigate();
+ const navigationType = useNavigationType(); // Check if there is a history state
+ const disabled = navigationType === 'POP';
+ const handleBack = () => {
+ navigate(-1);
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default BackButton;
diff --git a/src/pages/tools-by-category/index.tsx b/src/pages/tools-by-category/index.tsx
index db38c82..9d70b4d 100644
--- a/src/pages/tools-by-category/index.tsx
+++ b/src/pages/tools-by-category/index.tsx
@@ -8,6 +8,10 @@ import { capitalizeFirstLetter } from '@utils/string';
import { Icon } from '@iconify/react';
import { categoriesColors } from 'config/uiConfig';
import React, { useEffect } from 'react';
+import IconButton from '@mui/material/IconButton';
+import { ArrowBack } from '@mui/icons-material';
+import BackButton from '@components/BackButton';
+import ArrowBackIcon from '@mui/icons-material/ArrowBack';
export default function Home() {
const navigate = useNavigate();
@@ -35,10 +39,15 @@ export default function Home() {
- {`All ${capitalizeFirstLetter(categoryName)} Tools`}
+
+ navigate('/')}>
+
+
+ {`All ${capitalizeFirstLetter(categoryName)} Tools`}
+
{getToolsByCategory()
.find(({ type }) => type === categoryName)
diff --git a/src/pages/tools/csv/csv-rows-to-columns/csv-rows-to-columns.service.test.ts b/src/pages/tools/csv/csv-rows-to-columns/csv-rows-to-columns.service.test.ts
new file mode 100644
index 0000000..7faf0e7
--- /dev/null
+++ b/src/pages/tools/csv/csv-rows-to-columns/csv-rows-to-columns.service.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect } from 'vitest';
+import { csvRowsToColumns } from './service';
+
+describe('csvRowsToColumns', () => {
+ it('should transpose rows to columns', () => {
+ const input = 'a,b,c\n1,2,3\nx,y,z';
+ const result = csvRowsToColumns(input, false, '', '#');
+ expect(result).toBe('a,1,x\nb,2,y\nc,3,z');
+ });
+
+ it('should fill empty values with custom filler', () => {
+ const input = 'a,b\n1\nx,y,z';
+ const result = csvRowsToColumns(input, false, 'FILL', '#');
+ expect(result).toBe('a,1,x\nb,FILL,y\nFILL,FILL,z');
+ });
+
+ it('should fill empty values with empty strings when emptyValuesFilling is true', () => {
+ const input = 'a,b\n1\nx,y,z';
+ const result = csvRowsToColumns(input, true, 'FILL', '#');
+ expect(result).toBe('a,1,x\nb,,y\n,,z');
+ });
+
+ it('should ignore rows starting with the comment character', () => {
+ const input = '#comment\n1,2,3\nx,y,z';
+ const result = csvRowsToColumns(input, false, '', '#');
+ expect(result).toBe('1,x\n2,y\n3,z');
+ });
+
+ it('should handle an empty input', () => {
+ const input = '';
+ const result = csvRowsToColumns(input, false, '', '#');
+ expect(result).toBe('');
+ });
+
+ it('should handle input with only comments', () => {
+ const input = '#comment\n#another comment';
+ const result = csvRowsToColumns(input, false, '', '#');
+ expect(result).toBe('');
+ });
+
+ it('should handle single row input', () => {
+ const input = 'a,b,c';
+ const result = csvRowsToColumns(input, false, '', '#');
+ expect(result).toBe('a\nb\nc');
+ });
+
+ it('should handle single column input', () => {
+ const input = 'a\nb\nc';
+ const result = csvRowsToColumns(input, false, '', '#');
+ expect(result).toBe('a,b,c');
+ });
+
+ it('should handle this case onlinetools #1', () => {
+ const input =
+ 'Variety,Origin\nArabica,Ethiopia\nRobusta,Africa\nLiberica,Philippines\nMocha,\n//green tea';
+ const result = csvRowsToColumns(input, false, '1x', '//');
+ expect(result).toBe(
+ 'Variety,Arabica,Robusta,Liberica,Mocha\nOrigin,Ethiopia,Africa,Philippines,1x'
+ );
+ });
+});
diff --git a/src/pages/tools/csv/csv-rows-to-columns/index.tsx b/src/pages/tools/csv/csv-rows-to-columns/index.tsx
new file mode 100644
index 0000000..8b70b75
--- /dev/null
+++ b/src/pages/tools/csv/csv-rows-to-columns/index.tsx
@@ -0,0 +1,156 @@
+import React, { useState } from 'react';
+import { Box } from '@mui/material';
+import ToolContent from '@components/ToolContent';
+import { GetGroupsType } from '@components/options/ToolOptions';
+import { ToolComponentProps } from '@tools/defineTool';
+import ToolTextInput from '@components/input/ToolTextInput';
+import ToolTextResult from '@components/result/ToolTextResult';
+import { CardExampleType } from '@components/examples/ToolExamples';
+import SelectWithDesc from '@components/options/SelectWithDesc';
+import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
+import { csvRowsToColumns } from './service';
+
+const initialValues = {
+ emptyValuesFilling: false,
+ customFiller: 'x',
+ commentCharacter: '//'
+};
+type InitialValuesType = typeof initialValues;
+const exampleCards: CardExampleType[] = [
+ {
+ title: 'Convert CSV Rows to Columns',
+ description:
+ 'In this example, we transform the input CSV file with a single horizontal row of six values "a,b,c,d,e,f" into a vertical column. The program takes this row, rotates it 90 degrees, and outputs it as a column with each CSV value on a new line. This operation can also be viewed as converting a 6-dimensional row vector into a 6-dimensional column vector.',
+ sampleText: `a,b,c,d,e,f`,
+ sampleResult: `a
+b
+c
+d
+e
+f`,
+ sampleOptions: {
+ emptyValuesFilling: true,
+ customFiller: '1',
+ commentCharacter: '#'
+ }
+ },
+ {
+ title: 'Rows to Columns Transformation',
+ description:
+ 'In this example, we load a CSV file containing coffee varieties and their origins. The file is quite messy, with numerous empty lines and comments, and it is hard to work with. To clean up the file, we specify the comment pattern // in the options, and the program automatically removes the comment lines from the input. Also, the empty lines are automatically removed. Once the file is cleaned up, we transform the five clean rows into five columns, each having a height of two fields.',
+ sampleText: `Variety,Origin
+Arabica,Ethiopia
+
+Robusta,Africa
+Liberica,Philippines
+
+Mocha,Yemen
+//green tea`,
+ sampleResult: `Variety,Arabica,Robusta,Liberica,Mocha
+Origin,Ethiopia,Africa,Philippines,Yemen`,
+ sampleOptions: {
+ emptyValuesFilling: true,
+ customFiller: '1',
+ commentCharacter: '//'
+ }
+ },
+ {
+ title: 'Fill Missing Data',
+ description:
+ 'In this example, we swap rows and columns in CSV data about team sports, the equipment used, and the number of players. The input has 5 rows and 3 columns and once rows and columns have been swapped, the output has 3 rows and 5 columns. Also notice that in the last data record, for the "Baseball" game, the number of players is missing. To create a fully-filled CSV, we use a custom message "NA", specified in the options, and fill the missing CSV field with this value.',
+ sampleText: `Sport,Equipment,Players
+Basketball,Ball,5
+Football,Ball,11
+Soccer,Ball,11
+Baseball,Bat & Ball`,
+ sampleResult: `Sport,Basketball,Football,Soccer,Baseball
+Equipment,Ball,Ball,Ball,Bat & Ball
+Players,5,11,11,NA`,
+ sampleOptions: {
+ emptyValuesFilling: false,
+ customFiller: 'NA',
+ commentCharacter: '#'
+ }
+ }
+];
+
+export default function CsvRowsToColumns({
+ title,
+ longDescription
+}: ToolComponentProps) {
+ const [input, setInput] = useState('');
+ const [result, setResult] = useState('');
+
+ const compute = (optionsValues: typeof initialValues, input: string) => {
+ setResult(
+ csvRowsToColumns(
+ input,
+ optionsValues.emptyValuesFilling,
+ optionsValues.customFiller,
+ optionsValues.commentCharacter
+ )
+ );
+ };
+
+ const getGroups: GetGroupsType = ({
+ values,
+ updateField
+ }) => [
+ {
+ title: 'Fix incomplete data',
+ component: (
+
+ updateField('emptyValuesFilling', value)}
+ description={
+ 'If the input CSV file is incomplete (missing values), then add empty fields or custom symbols to records to make a well-formed CSV?'
+ }
+ />
+ {!values.emptyValuesFilling && (
+ updateField('customFiller', val)}
+ description={
+ 'Use this custom value to fill in missing fields. (Works only with "Custom Values" mode above.)'
+ }
+ />
+ )}
+
+ )
+ },
+ {
+ title: 'Lines with comments',
+ component: (
+
+ updateField('commentCharacter', val)}
+ description={
+ 'Enter the symbol indicating the start of a comment line. (These lines are removed during conversion.)'
+ }
+ />
+
+ )
+ }
+ ];
+
+ return (
+ }
+ resultComponent={}
+ initialValues={initialValues}
+ getGroups={getGroups}
+ setInput={setInput}
+ compute={compute}
+ toolInfo={{ title: `What is a ${title}?`, description: longDescription }}
+ exampleCards={exampleCards}
+ />
+ );
+}
diff --git a/src/pages/tools/csv/csv-rows-to-columns/meta.ts b/src/pages/tools/csv/csv-rows-to-columns/meta.ts
new file mode 100644
index 0000000..e4d8c3d
--- /dev/null
+++ b/src/pages/tools/csv/csv-rows-to-columns/meta.ts
@@ -0,0 +1,15 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('csv', {
+ name: 'Convert CSV Rows to Columns',
+ path: 'csv-rows-to-columns',
+ icon: 'carbon:transpose',
+ description:
+ 'This tool converts rows of a CSV (Comma Separated Values) file into columns. It extracts the horizontal lines from the input CSV one by one, rotates them 90 degrees, and outputs them as vertical columns one after another, separated by commas.',
+ longDescription:
+ 'This tool converts rows of a CSV (Comma Separated Values) file into columns. For example, if the input CSV data has 6 rows, then the output will have 6 columns and the elements of the rows will be arranged from the top to bottom. In a well-formed CSV, the number of values in each row is the same. However, in cases when rows are missing fields, the program can fix them and you can choose from the available options: fill missing data with empty elements or replace missing data with custom elements, such as "missing", "?", or "x". During the conversion process, the tool also cleans the CSV file from unnecessary information, such as empty lines (these are lines without visible information) and comments. To help the tool correctly identify comments, in the options, you can specify the symbol at the beginning of a line that starts a comment. This symbol is typically a hash "#" or double slash "//". Csv-abulous!.',
+ shortDescription: 'Convert CSV data to JSON format',
+ keywords: ['csv', 'rows', 'columns', 'transpose'],
+ component: lazy(() => import('./index'))
+});
diff --git a/src/pages/tools/csv/csv-rows-to-columns/service.ts b/src/pages/tools/csv/csv-rows-to-columns/service.ts
new file mode 100644
index 0000000..614043f
--- /dev/null
+++ b/src/pages/tools/csv/csv-rows-to-columns/service.ts
@@ -0,0 +1,40 @@
+function compute(rows: string[][], columnCount: number): string[][] {
+ const result: string[][] = [];
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ for (let j = 0; j < columnCount; j++) {
+ if (!result[j]) {
+ result[j] = [];
+ }
+ result[j][i] = row[j];
+ }
+ }
+ return result;
+}
+export function csvRowsToColumns(
+ input: string,
+ emptyValuesFilling: boolean,
+ customFiller: string,
+ commentCharacter: string
+): string {
+ if (!input) {
+ return '';
+ }
+
+ const rows = input
+ .split('\n')
+ .map((row) => row.split(','))
+ .filter(
+ (row) => row.length > 0 && !row[0].trim().startsWith(commentCharacter)
+ );
+ const columnCount = Math.max(...rows.map((row) => row.length));
+ for (let i = 0; i < rows.length; i++) {
+ for (let j = 0; j < columnCount; j++) {
+ if (!rows[i][j]) {
+ rows[i][j] = emptyValuesFilling ? '' : customFiller;
+ }
+ }
+ }
+ const result = compute(rows, columnCount);
+ return result.join('\n');
+}
diff --git a/src/pages/tools/csv/index.ts b/src/pages/tools/csv/index.ts
index d21922b..3480a71 100644
--- a/src/pages/tools/csv/index.ts
+++ b/src/pages/tools/csv/index.ts
@@ -1,4 +1,5 @@
import { tool as csvToJson } from './csv-to-json/meta';
import { tool as csvToXml } from './csv-to-xml/meta';
+import { tool as csvToRowsColumns } from './csv-rows-to-columns/meta';
-export const csvTools = [csvToJson, csvToXml];
+export const csvTools = [csvToJson, csvToXml, csvToRowsColumns];
diff --git a/src/pages/tools/time/index.ts b/src/pages/tools/time/index.ts
index f229937..9b80e65 100644
--- a/src/pages/tools/time/index.ts
+++ b/src/pages/tools/time/index.ts
@@ -1,3 +1,4 @@
+import { tool as timeBetweenDates } from './time-between-dates/meta';
import { tool as daysDoHours } from './convert-days-to-hours/meta';
import { tool as hoursToDays } from './convert-hours-to-days/meta';
import { tool as convertSecondsToTime } from './convert-seconds-to-time/meta';
@@ -9,5 +10,6 @@ export const timeTools = [
hoursToDays,
convertSecondsToTime,
convertTimetoSeconds,
- truncateClockTime
+ truncateClockTime,
+ timeBetweenDates
];
diff --git a/src/pages/tools/time/time-between-dates/index.tsx b/src/pages/tools/time/time-between-dates/index.tsx
new file mode 100644
index 0000000..a02ee70
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/index.tsx
@@ -0,0 +1,250 @@
+import { Box, Paper, Typography } from '@mui/material';
+import React, { useState } from 'react';
+import ToolContent from '@components/ToolContent';
+import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
+import SelectWithDesc from '@components/options/SelectWithDesc';
+import {
+ calculateTimeBetweenDates,
+ formatTimeWithLargestUnit,
+ getTimeWithTimezone,
+ unitHierarchy
+} from './service';
+import * as Yup from 'yup';
+import { CardExampleType } from '@components/examples/ToolExamples';
+
+type TimeUnit =
+ | 'milliseconds'
+ | 'seconds'
+ | 'minutes'
+ | 'hours'
+ | 'days'
+ | 'months'
+ | 'years';
+
+type InitialValuesType = {
+ startDate: string;
+ startTime: string;
+ endDate: string;
+ endTime: string;
+ startTimezone: string;
+ endTimezone: string;
+};
+
+const initialValues: InitialValuesType = {
+ startDate: new Date().toISOString().split('T')[0],
+ startTime: '00:00',
+ endDate: new Date().toISOString().split('T')[0],
+ endTime: '12:00',
+ startTimezone: 'local',
+ endTimezone: 'local'
+};
+
+const validationSchema = Yup.object({
+ startDate: Yup.string().required('Start date is required'),
+ startTime: Yup.string().required('Start time is required'),
+ endDate: Yup.string().required('End date is required'),
+ endTime: Yup.string().required('End time is required'),
+ startTimezone: Yup.string(),
+ endTimezone: Yup.string()
+});
+
+const timezoneOptions = [
+ { value: 'local', label: 'Local Time' },
+ ...Intl.supportedValuesOf('timeZone')
+ .map((tz) => {
+ const formatter = new Intl.DateTimeFormat('en', {
+ timeZone: tz,
+ timeZoneName: 'shortOffset'
+ });
+
+ const offset =
+ formatter
+ .formatToParts(new Date())
+ .find((part) => part.type === 'timeZoneName')?.value || '';
+
+ return {
+ value: offset.replace('UTC', 'GMT'),
+ label: `${offset.replace('UTC', 'GMT')} (${tz})`
+ };
+ })
+ .sort((a, b) =>
+ a.value.localeCompare(b.value, undefined, { numeric: true })
+ )
+];
+
+const exampleCards: CardExampleType[] = [
+ {
+ title: 'One Year Difference',
+ description: 'Calculate the time between dates that are one year apart',
+ sampleOptions: {
+ startDate: '2023-01-01',
+ startTime: '12:00',
+ endDate: '2024-01-01',
+ endTime: '12:00',
+ startTimezone: 'local',
+ endTimezone: 'local'
+ },
+ sampleResult: '1 year'
+ },
+ {
+ title: 'Different Timezones',
+ description: 'Calculate the time difference between New York and London',
+ sampleOptions: {
+ startDate: '2023-01-01',
+ startTime: '12:00',
+ endDate: '2023-01-01',
+ endTime: '12:00',
+ startTimezone: 'GMT-5',
+ endTimezone: 'GMT'
+ },
+ sampleResult: '5 hours'
+ },
+ {
+ title: 'Detailed Time Breakdown',
+ description: 'Show a detailed breakdown of a time difference',
+ sampleOptions: {
+ startDate: '2023-01-01',
+ startTime: '09:30',
+ endDate: '2023-01-03',
+ endTime: '14:45',
+ startTimezone: 'local',
+ endTimezone: 'local'
+ },
+ sampleResult: '2 days, 5 hours, 15 minutes'
+ }
+];
+
+export default function TimeBetweenDates() {
+ const [result, setResult] = useState('');
+
+ return (
+
+
+ {result}
+
+
+ ) : null
+ }
+ initialValues={initialValues}
+ validationSchema={validationSchema}
+ exampleCards={exampleCards}
+ toolInfo={{
+ title: 'Time Between Dates Calculator',
+ description:
+ 'Calculate the exact time difference between two dates and times, with support for different timezones. This tool provides a detailed breakdown of the time difference in various units (years, months, days, hours, minutes, and seconds).'
+ }}
+ getGroups={({ values, updateField }) => [
+ {
+ title: 'Start Date & Time',
+ component: (
+
+ updateField('startDate', val)}
+ type="date"
+ />
+ updateField('startTime', val)}
+ type="time"
+ />
+ updateField('startTimezone', val)}
+ options={timezoneOptions}
+ />
+
+ )
+ },
+ {
+ title: 'End Date & Time',
+ component: (
+
+ updateField('endDate', val)}
+ type="date"
+ />
+ updateField('endTime', val)}
+ type="time"
+ />
+ updateField('endTimezone', val)}
+ options={timezoneOptions}
+ />
+
+ )
+ }
+ ]}
+ compute={(values) => {
+ try {
+ const startDateTime = getTimeWithTimezone(
+ values.startDate,
+ values.startTime,
+ values.startTimezone
+ );
+
+ const endDateTime = getTimeWithTimezone(
+ values.endDate,
+ values.endTime,
+ values.endTimezone
+ );
+
+ // Calculate time difference
+ const difference = calculateTimeBetweenDates(
+ startDateTime,
+ endDateTime
+ );
+
+ // Auto-determine the best unit to display based on the time difference
+ const bestUnit: TimeUnit =
+ unitHierarchy.find((unit) => difference[unit] > 0) ||
+ 'milliseconds';
+
+ const formattedDifference = formatTimeWithLargestUnit(
+ difference,
+ bestUnit
+ );
+
+ setResult(formattedDifference);
+ } catch (error) {
+ setResult(
+ `Error: ${
+ error instanceof Error
+ ? error.message
+ : 'Failed to calculate time difference'
+ }`
+ );
+ }
+ }}
+ />
+ );
+}
diff --git a/src/pages/tools/time/time-between-dates/meta.ts b/src/pages/tools/time/time-between-dates/meta.ts
new file mode 100644
index 0000000..a94f6ec
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/meta.ts
@@ -0,0 +1,22 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('time', {
+ name: 'Time Between Dates',
+ path: 'time-between-dates',
+ icon: 'tabler:clock-minus',
+ description:
+ 'Calculate the exact time difference between two dates and times, with support for different timezones. This tool provides a detailed breakdown of the time difference in various units (years, months, days, hours, minutes, and seconds).',
+ shortDescription:
+ 'Calculate the precise time duration between two dates with timezone support.',
+ keywords: [
+ 'time',
+ 'dates',
+ 'difference',
+ 'duration',
+ 'calculator',
+ 'timezones',
+ 'interval'
+ ],
+ component: lazy(() => import('./index'))
+});
diff --git a/src/pages/tools/time/time-between-dates/service.ts b/src/pages/tools/time/time-between-dates/service.ts
new file mode 100644
index 0000000..785f392
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/service.ts
@@ -0,0 +1,129 @@
+export const unitHierarchy = [
+ 'years',
+ 'months',
+ 'days',
+ 'hours',
+ 'minutes',
+ 'seconds',
+ 'milliseconds'
+] as const;
+
+export type TimeUnit = (typeof unitHierarchy)[number];
+export type TimeDifference = Record;
+
+export const calculateTimeBetweenDates = (
+ startDate: Date,
+ endDate: Date
+): TimeDifference => {
+ if (endDate < startDate) {
+ const temp = startDate;
+ startDate = endDate;
+ endDate = temp;
+ }
+
+ const milliseconds = endDate.getTime() - startDate.getTime();
+ const seconds = Math.floor(milliseconds / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ // Approximate months and years
+ const startYear = startDate.getFullYear();
+ const startMonth = startDate.getMonth();
+ const endYear = endDate.getFullYear();
+ const endMonth = endDate.getMonth();
+
+ const months = (endYear - startYear) * 12 + (endMonth - startMonth);
+ const years = Math.floor(months / 12);
+
+ return {
+ milliseconds,
+ seconds,
+ minutes,
+ hours,
+ days,
+ months,
+ years
+ };
+};
+
+export const formatTimeDifference = (
+ difference: TimeDifference,
+ includeUnits: TimeUnit[] = unitHierarchy.slice(0, -1)
+): string => {
+ const timeUnits: { key: TimeUnit; value: number; divisor?: number }[] = [
+ { key: 'years', value: difference.years },
+ { key: 'months', value: difference.months, divisor: 12 },
+ { key: 'days', value: difference.days, divisor: 30 },
+ { key: 'hours', value: difference.hours, divisor: 24 },
+ { key: 'minutes', value: difference.minutes, divisor: 60 },
+ { key: 'seconds', value: difference.seconds, divisor: 60 }
+ ];
+
+ const parts = timeUnits
+ .filter(({ key }) => includeUnits.includes(key))
+ .map(({ key, value, divisor }) => {
+ const remaining = divisor ? value % divisor : value;
+ return remaining > 0 ? `${remaining} ${key}` : '';
+ })
+ .filter(Boolean);
+
+ if (parts.length === 0) {
+ if (includeUnits.includes('milliseconds')) {
+ return `${difference.milliseconds} millisecond${
+ difference.milliseconds === 1 ? '' : 's'
+ }`;
+ }
+ return '0 seconds';
+ }
+
+ return parts.join(', ');
+};
+
+export const getTimeWithTimezone = (
+ dateString: string,
+ timeString: string,
+ timezone: string
+): Date => {
+ // Combine date and time
+ const dateTimeString = `${dateString}T${timeString}Z`; // Append 'Z' to enforce UTC parsing
+ const utcDate = new Date(dateTimeString);
+
+ if (isNaN(utcDate.getTime())) {
+ throw new Error('Invalid date or time format');
+ }
+
+ // If timezone is "local", return the local date
+ if (timezone === 'local') {
+ return utcDate;
+ }
+
+ // Extract offset from timezone (e.g., "GMT+5:30" or "GMT-4")
+ const match = timezone.match(/^GMT(?:([+-]\d{1,2})(?::(\d{2}))?)?$/);
+ if (!match) {
+ throw new Error('Invalid timezone format');
+ }
+
+ const offsetHours = match[1] ? parseInt(match[1], 10) : 0;
+ const offsetMinutes = match[2] ? parseInt(match[2], 10) : 0;
+
+ const totalOffsetMinutes =
+ offsetHours * 60 + (offsetHours < 0 ? -offsetMinutes : offsetMinutes);
+
+ // Adjust the UTC date by the timezone offset
+ return new Date(utcDate.getTime() - totalOffsetMinutes * 60 * 1000);
+};
+
+// Helper function to format time based on largest unit
+export const formatTimeWithLargestUnit = (
+ difference: TimeDifference,
+ largestUnit: TimeUnit
+): string => {
+ const largestUnitIndex = unitHierarchy.indexOf(largestUnit);
+ const unitsToInclude = unitHierarchy.slice(largestUnitIndex);
+
+ // Preserve only whole values, do not apply fractional conversions
+ const adjustedDifference: TimeDifference = { ...difference };
+
+ return formatTimeDifference(adjustedDifference, unitsToInclude);
+};
diff --git a/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts
new file mode 100644
index 0000000..5a8db7f
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import {
+ calculateTimeBetweenDates,
+ formatTimeDifference,
+ formatTimeWithLargestUnit,
+ getTimeWithTimezone
+} from './service';
+
+// Utility function to create a date
+const createDate = (
+ year: number,
+ month: number,
+ day: number,
+ hours = 0,
+ minutes = 0,
+ seconds = 0
+) => new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds));
+describe('calculateTimeBetweenDates', () => {
+ it('should calculate the correct time difference', () => {
+ const startDate = createDate(2023, 1, 1);
+ const endDate = createDate(2024, 1, 1);
+ const result = calculateTimeBetweenDates(startDate, endDate);
+
+ expect(result.years).toBe(1);
+ expect(result.months).toBe(12);
+ expect(result.days).toBeGreaterThanOrEqual(365);
+ });
+
+ it('should swap dates if startDate is after endDate', () => {
+ const startDate = createDate(2024, 1, 1);
+ const endDate = createDate(2023, 1, 1);
+ const result = calculateTimeBetweenDates(startDate, endDate);
+ expect(result.years).toBe(1);
+ });
+});
+
+describe('formatTimeDifference', () => {
+ it('should format time difference correctly', () => {
+ const difference = {
+ years: 1,
+ months: 2,
+ days: 10,
+ hours: 5,
+ minutes: 30,
+ seconds: 0,
+ milliseconds: 0
+ };
+ expect(formatTimeDifference(difference)).toBe(
+ '1 years, 2 months, 10 days, 5 hours, 30 minutes'
+ );
+ });
+
+ it('should return 0 seconds if all values are zero', () => {
+ expect(
+ formatTimeDifference({
+ years: 0,
+ months: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ milliseconds: 0
+ })
+ ).toBe('0 seconds');
+ });
+});
+
+describe('getTimeWithTimezone', () => {
+ it('should convert UTC date to specified timezone', () => {
+ const date = getTimeWithTimezone('2025-03-27', '12:00:00', 'GMT+2');
+ expect(date.getUTCHours()).toBe(10); // 12:00 GMT+2 is 10:00 UTC
+ });
+
+ it('should throw error for invalid timezone', () => {
+ expect(() =>
+ getTimeWithTimezone('2025-03-27', '12:00:00', 'INVALID')
+ ).toThrow('Invalid timezone format');
+ });
+});
+
+describe('formatTimeWithLargestUnit', () => {
+ it('should format time with the largest unit', () => {
+ const difference = {
+ years: 0,
+ months: 1,
+ days: 15,
+ hours: 12,
+ minutes: 0,
+ seconds: 0,
+ milliseconds: 0
+ };
+ expect(formatTimeWithLargestUnit(difference, 'days')).toContain(
+ '15 days, 12 hours'
+ );
+ });
+});
diff --git a/src/pages/tools/video/index.ts b/src/pages/tools/video/index.ts
index bbcc497..9a9dd35 100644
--- a/src/pages/tools/video/index.ts
+++ b/src/pages/tools/video/index.ts
@@ -1,4 +1,6 @@
+import { rotate } from '../string/rotate/service';
import { gifTools } from './gif';
import { tool as trimVideo } from './trim/meta';
+import { tool as rotateVideo } from './rotate/meta';
-export const videoTools = [...gifTools, trimVideo];
+export const videoTools = [...gifTools, trimVideo, rotateVideo];
diff --git a/src/pages/tools/video/rotate/index.tsx b/src/pages/tools/video/rotate/index.tsx
new file mode 100644
index 0000000..40a7b70
--- /dev/null
+++ b/src/pages/tools/video/rotate/index.tsx
@@ -0,0 +1,111 @@
+import { Box } from '@mui/material';
+import React, { useCallback, useState } from 'react';
+import * as Yup from 'yup';
+import ToolFileResult from '@components/result/ToolFileResult';
+import ToolContent from '@components/ToolContent';
+import { ToolComponentProps } from '@tools/defineTool';
+import { GetGroupsType } from '@components/options/ToolOptions';
+import { debounce } from 'lodash';
+import ToolVideoInput from '@components/input/ToolVideoInput';
+import { rotateVideo } from './service';
+import { RotationAngle } from '../../pdf/rotate-pdf/types';
+import SimpleRadio from '@components/options/SimpleRadio';
+
+export const initialValues = {
+ rotation: 90
+};
+
+export const validationSchema = Yup.object({
+ rotation: Yup.number()
+ .oneOf([0, 90, 180, 270], 'Rotation must be 0, 90, 180, or 270 degrees')
+ .required('Rotation is required')
+});
+
+const angleOptions: { value: RotationAngle; label: string }[] = [
+ { value: 90, label: '90° Clockwise' },
+ { value: 180, label: '180° (Upside down)' },
+ { value: 270, label: '270° (90° Counter-clockwise)' }
+];
+export default function RotateVideo({ title }: ToolComponentProps) {
+ const [input, setInput] = useState(null);
+ const [result, setResult] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const compute = async (
+ optionsValues: typeof initialValues,
+ input: File | null
+ ) => {
+ if (!input) return;
+ setLoading(true);
+
+ try {
+ const rotatedFile = await rotateVideo(input, optionsValues.rotation);
+ setResult(rotatedFile);
+ } catch (error) {
+ console.error('Error rotating video:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const debouncedCompute = useCallback(debounce(compute, 1000), []);
+
+ const getGroups: GetGroupsType = ({
+ values,
+ updateField
+ }) => [
+ {
+ title: 'Rotation',
+ component: (
+
+ {angleOptions.map((angleOption) => (
+ {
+ updateField('rotation', angleOption.value);
+ }}
+ />
+ ))}
+
+ )
+ }
+ ];
+
+ return (
+
+ }
+ resultComponent={
+ loading ? (
+
+ ) : (
+
+ )
+ }
+ initialValues={initialValues}
+ getGroups={getGroups}
+ compute={debouncedCompute}
+ setInput={setInput}
+ validationSchema={validationSchema}
+ />
+ );
+}
diff --git a/src/pages/tools/video/rotate/meta.ts b/src/pages/tools/video/rotate/meta.ts
new file mode 100644
index 0000000..0bcf327
--- /dev/null
+++ b/src/pages/tools/video/rotate/meta.ts
@@ -0,0 +1,13 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('video', {
+ name: 'Rotate Video',
+ path: 'rotate',
+ icon: 'mdi:rotate-right',
+ description:
+ 'This online utility lets you rotate videos by 90, 180, or 270 degrees. You can preview the rotated video before processing. Supports common video formats like MP4, WebM, and OGG.',
+ shortDescription: 'Rotate videos by 90, 180, or 270 degrees',
+ keywords: ['rotate', 'video', 'flip', 'edit', 'adjust'],
+ component: lazy(() => import('./index'))
+});
diff --git a/src/pages/tools/video/rotate/service.ts b/src/pages/tools/video/rotate/service.ts
new file mode 100644
index 0000000..6b31770
--- /dev/null
+++ b/src/pages/tools/video/rotate/service.ts
@@ -0,0 +1,44 @@
+import { FFmpeg } from '@ffmpeg/ffmpeg';
+import { fetchFile } from '@ffmpeg/util';
+
+const ffmpeg = new FFmpeg();
+
+export async function rotateVideo(
+ input: File,
+ rotation: number
+): Promise {
+ if (!ffmpeg.loaded) {
+ await ffmpeg.load({
+ wasmURL:
+ 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
+ });
+ }
+
+ const inputName = 'input.mp4';
+ const outputName = 'output.mp4';
+ await ffmpeg.writeFile(inputName, await fetchFile(input));
+
+ const rotateMap: Record = {
+ 90: 'transpose=1',
+ 180: 'transpose=2,transpose=2',
+ 270: 'transpose=2',
+ 0: ''
+ };
+ const rotateFilter = rotateMap[rotation];
+
+ const args = ['-i', inputName];
+ if (rotateFilter) {
+ args.push('-vf', rotateFilter);
+ }
+
+ args.push('-c:v', 'libx264', '-preset', 'ultrafast', outputName);
+
+ await ffmpeg.exec(args);
+
+ const rotatedData = await ffmpeg.readFile(outputName);
+ return new File(
+ [new Blob([rotatedData], { type: 'video/mp4' })],
+ `${input.name.replace(/\.[^/.]+$/, '')}_rotated.mp4`,
+ { type: 'video/mp4' }
+ );
+}
diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx
index e32b940..5263c6f 100644
--- a/src/tools/defineTool.tsx
+++ b/src/tools/defineTool.tsx
@@ -21,6 +21,7 @@ export type ToolCategory =
| 'video'
| 'list'
| 'json'
+ | 'time'
| 'csv'
| 'time'
| 'pdf';
diff --git a/src/tools/index.ts b/src/tools/index.ts
index e57bdcc..401cbb3 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -66,6 +66,12 @@ const categoriesConfig: {
value:
'Tools for working with JSON data structures – prettify and minify JSON objects, flatten JSON arrays, stringify JSON values, analyze data, and much more'
},
+ {
+ type: 'time',
+ icon: 'mdi:clock-time-five',
+ value:
+ 'Tools for working with time and date – calculate time differences, convert between time zones, format dates, generate date sequences, and much more.'
+ },
{
type: 'csv',
icon: 'material-symbols-light:csv-outline',
diff --git a/vite.config.ts b/vite.config.ts
index 8115376..ac5243e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,6 +6,9 @@ import tsconfigPaths from 'vite-tsconfig-paths';
// https://vitejs.dev/config https://vitest.dev/config
export default defineConfig({
plugins: [react(), tsconfigPaths()],
+ define: {
+ 'process.env': {}
+ },
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
},