diff --git a/.idea/workspace.xml b/.idea/workspace.xml index e15ea9d..53a0e44 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,12 +4,13 @@ - + + - - - - + + + + @@ -26,7 +27,7 @@ - + @@ -126,15 +127,16 @@ "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", - "git-widget-placeholder": "#53 on fork/ARRY7686/csvToXml", + "git-widget-placeholder": "split-pdf", "ignore.virus.scanning.warn.message": "true", "kotlin-language-version-configured": "true", - "last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/src/pages/tools/list/duplicate/index.tsx", + "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)", @@ -168,11 +170,11 @@ + - @@ -182,7 +184,7 @@ - + @@ -196,6 +198,19 @@ + + + + + + + + + + + + + @@ -226,16 +241,6 @@ - - - - - - - - - - @@ -247,19 +252,19 @@ - + + - @@ -344,30 +349,6 @@ - - - 1740488522618 - - - - 1740488522618 - - - - 1740490919407 - - - - 1740490919407 - - - - 1740491274739 - - - - 1740491274739 - 1740491737480 @@ -736,7 +717,31 @@ 1741580736359 - + + + 1742960931740 + + + + 1742960931740 + + + + 1742961898820 + + + + 1742961898820 + + + + 1742967844908 + + + + 1742967844908 + + @@ -783,9 +788,6 @@ - - - @@ -808,7 +810,10 @@ - + + + + diff --git a/package-lock.json b/package-lock.json index 4d80209..d23e3e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@ffmpeg/core": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", + "@imgly/background-removal": "^1.6.0", "@jimp/types": "^1.6.0", "@mui/icons-material": "^5.15.20", "@mui/material": "^5.15.20", @@ -30,6 +31,7 @@ "morsee": "^1.0.9", "notistack": "^3.0.1", "omggif": "^1.0.10", + "pdf-lib": "^1.17.1", "playwright": "^1.45.0", "rc-slider": "^11.1.8", "react": "^18.3.1", @@ -1528,6 +1530,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@imgly/background-removal": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@imgly/background-removal/-/background-removal-1.6.0.tgz", + "integrity": "sha512-nmqOBDE9dQpDEJg73XrKNUoWugyyDHEVh+U1akjYdUW85ILh9UilvKu/kdv1MI822rKExwgLNuVLVulzAzgZJg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "lodash-es": "^4.17.21", + "ndarray": "~1.0.0", + "zod": "^3.23.8" + }, + "peerDependencies": { + "onnxruntime-web": "1.21.0-dev.20250206-d981b153d3" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2311,6 +2327,24 @@ "node": ">= 8" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2362,6 +2396,80 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "peer": true + }, "node_modules/@remix-run/router": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", @@ -3022,7 +3130,6 @@ "version": "20.14.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.6.tgz", "integrity": "sha512-JbA0XIJPL1IiNnU7PFxDXyfAwcwVVrOoqyzzyQTyMeVhBzkJVMSkC1LlVsRQ2lpqiY4n6Bb9oCS6lzDKVQxbZw==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -5591,6 +5698,13 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flatbuffers": { + "version": "25.2.10", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.2.10.tgz", + "integrity": "sha512-7JlN9ZvLDG1McO3kbX0k4v+SUAg48L1rIwEvN6ZQl/eCtgJz9UylTMzE9wrmYrcorgxm3CX/3T/w5VAub99UUw==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/flatted": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", @@ -6033,6 +6147,13 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC", + "peer": true + }, "node_modules/happy-dom": { "version": "12.10.3", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-12.10.3.tgz", @@ -6300,6 +6421,12 @@ "node": ">= 0.4" } }, + "node_modules/iota-array": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", + "integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==", + "license": "MIT" + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -6392,6 +6519,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -7389,6 +7522,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/long": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", + "integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==", + "license": "Apache-2.0", + "peer": true + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7652,6 +7792,16 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/ndarray": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz", + "integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==", + "license": "MIT", + "dependencies": { + "iota-array": "^1.0.0", + "is-buffer": "^1.0.2" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -7912,6 +8062,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/onnxruntime-common": { + "version": "1.21.0-dev.20250206-d981b153d3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0-dev.20250206-d981b153d3.tgz", + "integrity": "sha512-TwaE51xV9q2y8pM61q73rbywJnusw9ivTEHAJ39GVWNZqxCoDBpe/tQkh/w9S+o/g+zS7YeeL0I/2mEWd+dgyA==", + "license": "MIT", + "peer": true + }, + "node_modules/onnxruntime-web": { + "version": "1.21.0-dev.20250206-d981b153d3", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.21.0-dev.20250206-d981b153d3.tgz", + "integrity": "sha512-esDVQdRic6J44VBMFLumYvcGfioMh80ceLmzF1yheJyuLKq/Th8VT2aj42XWQst+2bcWnAhw4IKmRQaqzU8ugg==", + "license": "MIT", + "peer": true, + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.21.0-dev.20250206-d981b153d3", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -8102,6 +8274,24 @@ "through": "~2.3" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/peek-readable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz", @@ -8196,6 +8386,13 @@ "pathe": "^1.1.2" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT", + "peer": true + }, "node_modules/playwright": { "version": "1.45.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.45.0.tgz", @@ -8511,6 +8708,31 @@ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -10180,8 +10402,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicorn-magic": { "version": "0.1.0", diff --git a/package.json b/package.json index b69da3d..d97f8e3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@ffmpeg/core": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.15", "@ffmpeg/util": "^0.12.2", + "@imgly/background-removal": "^1.6.0", "@jimp/types": "^1.6.0", "@mui/icons-material": "^5.15.20", "@mui/material": "^5.15.20", @@ -47,6 +48,7 @@ "morsee": "^1.0.9", "notistack": "^3.0.1", "omggif": "^1.0.10", + "pdf-lib": "^1.17.1", "playwright": "^1.45.0", "rc-slider": "^11.1.8", "react": "^18.3.1", diff --git a/src/components/ToolContent.tsx b/src/components/ToolContent.tsx index a1aa62c..2e3a6b6 100644 --- a/src/components/ToolContent.tsx +++ b/src/components/ToolContent.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode, useContext } from 'react'; +import React, { ReactNode, useContext, useEffect } from 'react'; import { Box } from '@mui/material'; import { Formik, FormikValues, useFormikContext } from 'formik'; import ToolOptions, { GetGroupsType } from '@components/options/ToolOptions'; @@ -13,10 +13,12 @@ import { CustomSnackBarContext } from '../contexts/CustomSnackBarContext'; const FormikListenerComponent = ({ input, - compute + compute, + onValuesChange }: { input: any; compute: (optionsValues: T, input: any) => void; + onValuesChange?: (values: T) => void; }) => { const { values } = useFormikContext(); const { showSnackBar } = useContext(CustomSnackBarContext); @@ -30,40 +32,31 @@ const FormikListenerComponent = ({ } }, [values, input, showSnackBar]); + useEffect(() => { + onValuesChange?.(values); + }, [onValuesChange, values]); return null; // This component doesn't render anything }; interface ToolContentProps extends ToolComponentProps { - // Input/Output components inputComponent?: ReactNode; resultComponent: ReactNode; - renderCustomInput?: ( values: T, setFieldValue: (fieldName: string, value: any) => void ) => ReactNode; - - // Tool options initialValues: T; getGroups: GetGroupsType | null; - - // Computation function compute: (optionsValues: T, input: I) => void; - - // Tool info (optional) toolInfo?: { title: string; description?: string; }; - - // Input value to pass to the compute function input?: I; - exampleCards?: CardExampleType[]; setInput?: React.Dispatch>; - - // Validation schema (optional) validationSchema?: any; + onValuesChange?: (values: T) => void; } export default function ToolContent({ @@ -78,7 +71,8 @@ export default function ToolContent({ input, setInput, validationSchema, - renderCustomInput + renderCustomInput, + onValuesChange }: ToolContentProps) { return ( @@ -98,7 +92,11 @@ export default function ToolContent({ } result={resultComponent} /> - compute={compute} input={input} /> + + compute={compute} + input={input} + onValuesChange={onValuesChange} + /> {toolInfo && toolInfo.title && toolInfo.description && ( diff --git a/src/components/input/BaseFileInput.tsx b/src/components/input/BaseFileInput.tsx new file mode 100644 index 0000000..66c1241 --- /dev/null +++ b/src/components/input/BaseFileInput.tsx @@ -0,0 +1,147 @@ +import React, { ReactNode, useContext, useEffect } from 'react'; +import { Box, useTheme } from '@mui/material'; +import Typography from '@mui/material/Typography'; +import InputHeader from '../InputHeader'; +import InputFooter from './InputFooter'; +import { + BaseFileInputProps, + createObjectURL, + revokeObjectURL +} from './file-input-utils'; +import { globalInputHeight } from '../../config/uiConfig'; +import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; +import greyPattern from '@assets/grey-pattern.png'; + +interface BaseFileInputComponentProps extends BaseFileInputProps { + children: (props: { preview: string | undefined }) => ReactNode; + type: 'image' | 'video' | 'audio' | 'pdf'; +} + +export default function BaseFileInput({ + value, + onChange, + accept, + title, + children, + type +}: BaseFileInputComponentProps) { + const [preview, setPreview] = React.useState(null); + const theme = useTheme(); + const fileInputRef = React.useRef(null); + const { showSnackBar } = useContext(CustomSnackBarContext); + + useEffect(() => { + if (value) { + const objectUrl = createObjectURL(value); + setPreview(objectUrl); + + return () => revokeObjectURL(objectUrl); + } else { + setPreview(null); + } + }, [value]); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) onChange(file); + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + 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(() => { + 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]); + + return ( + + + + {preview ? ( + + {children({ preview })} + + ) : ( + + + 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 + + + )} + + + + + ); +} diff --git a/src/components/input/ToolImageInput.tsx b/src/components/input/ToolImageInput.tsx new file mode 100644 index 0000000..b493d41 --- /dev/null +++ b/src/components/input/ToolImageInput.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Box } from '@mui/material'; +import ReactCrop, { Crop, PixelCrop } from 'react-image-crop'; +import 'react-image-crop/dist/ReactCrop.css'; +import BaseFileInput from './BaseFileInput'; +import { BaseFileInputProps } from './file-input-utils'; +import { globalInputHeight } from '../../config/uiConfig'; + +interface ImageFileInputProps extends BaseFileInputProps { + 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; +} + +export default function ToolImageInput({ + showCropOverlay = false, + cropShape = 'rectangular', + cropPosition = { x: 0, y: 0 }, + cropSize = { width: 100, height: 100 }, + onCropChange, + ...props +}: ImageFileInputProps) { + const imageRef = useRef(null); + const [imgWidth, setImgWidth] = useState(0); + const [imgHeight, setImgHeight] = useState(0); + + const [crop, setCrop] = useState({ + unit: 'px', + x: 0, + y: 0, + width: 0, + height: 0 + }); + + const RATIO = imageRef.current ? imgWidth / imageRef.current.width : 1; + + const onImageLoad = (e: React.SyntheticEvent) => { + const { naturalWidth: width, naturalHeight: height } = e.currentTarget; + setImgWidth(width); + setImgHeight(height); + + 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); + + onCropChange( + { x: initialCrop.x, y: initialCrop.y }, + { width: initialCrop.width, height: initialCrop.height } + ); + } + }; + useEffect(() => { + if ( + imgWidth && + imgHeight && + (cropPosition.x !== 0 || + cropPosition.y !== 0 || + cropSize.width !== 100 || + cropSize.height !== 100) + ) { + setCrop({ + unit: 'px', + x: cropPosition.x / RATIO, + y: cropPosition.y / RATIO, + width: cropSize.width / RATIO, + height: cropSize.height / RATIO + }); + } + }, [cropPosition, cropSize, imgWidth, imgHeight, RATIO]); + + 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) + } + ); + } + }; + + return ( + + {({ preview }) => ( + + {showCropOverlay ? ( + + + + ) : ( + + )} + + )} + + ); +} diff --git a/src/components/input/ToolPdfInput.tsx b/src/components/input/ToolPdfInput.tsx new file mode 100644 index 0000000..f236283 --- /dev/null +++ b/src/components/input/ToolPdfInput.tsx @@ -0,0 +1,23 @@ +import React, { useRef } from 'react'; +import BaseFileInput from './BaseFileInput'; +import { BaseFileInputProps } from './file-input-utils'; + +interface PdfFileInputProps extends BaseFileInputProps {} + +export default function ToolPdfInput({ ...props }: PdfFileInputProps) { + const pdfRef = useRef(null); + + return ( + + {({ preview }) => ( + + )} + + ); +} diff --git a/src/components/input/ToolVideoInput.tsx b/src/components/input/ToolVideoInput.tsx new file mode 100644 index 0000000..e1b0136 --- /dev/null +++ b/src/components/input/ToolVideoInput.tsx @@ -0,0 +1,121 @@ +import React, { useRef, useState } from 'react'; +import { Box, Typography } from '@mui/material'; +import Slider from 'rc-slider'; +import 'rc-slider/assets/index.css'; +import BaseFileInput from './BaseFileInput'; +import { BaseFileInputProps, formatTime } from './file-input-utils'; + +interface VideoFileInputProps extends BaseFileInputProps { + showTrimControls?: boolean; + onTrimChange?: (trimStart: number, trimEnd: number) => void; + trimStart?: number; + trimEnd?: number; +} + +export default function ToolVideoInput({ + showTrimControls = false, + onTrimChange, + trimStart = 0, + trimEnd = 100, + ...props +}: VideoFileInputProps) { + const videoRef = useRef(null); + const [videoDuration, setVideoDuration] = useState(0); + + const onVideoLoad = (e: React.SyntheticEvent) => { + const duration = e.currentTarget.duration; + setVideoDuration(duration); + + if (onTrimChange && trimStart === 0 && trimEnd === 100) { + onTrimChange(0, duration); + } + }; + + const handleTrimChange = (start: number, end: number) => { + if (onTrimChange) { + onTrimChange(start, end); + } + }; + + return ( + + {({ preview }) => ( + + + + {showTrimControls && videoDuration > 0 && ( + + + + Start: {formatTime(trimStart || 0)} + + + End: {formatTime(trimEnd || videoDuration)} + + + + + { + if (Array.isArray(values)) { + handleTrimChange(values[0], values[1]); + } + }} + allowCross={false} + pushable={0.1} + /> + + + + )} + + )} + + ); +} diff --git a/src/components/input/file-input-utils.ts b/src/components/input/file-input-utils.ts new file mode 100644 index 0000000..e4c54f3 --- /dev/null +++ b/src/components/input/file-input-utils.ts @@ -0,0 +1,22 @@ +export interface BaseFileInputProps { + value: File | null; + onChange: (file: File) => void; + accept: string[]; + title?: string; +} + +export const formatTime = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes.toString().padStart(2, '0')}:${remainingSeconds + .toString() + .padStart(2, '0')}`; +}; + +export const createObjectURL = (file: File): string => { + return URL.createObjectURL(file); +}; + +export const revokeObjectURL = (url: string): void => { + URL.revokeObjectURL(url); +}; diff --git a/src/components/result/ToolFileResult.tsx b/src/components/result/ToolFileResult.tsx index d39afeb..315ed63 100644 --- a/src/components/result/ToolFileResult.tsx +++ b/src/components/result/ToolFileResult.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material'; +import { Box, CircularProgress, Typography } from '@mui/material'; import React, { useContext } from 'react'; import InputHeader from '../InputHeader'; import greyPattern from '@assets/grey-pattern.png'; @@ -9,11 +9,15 @@ import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext'; export default function ToolFileResult({ title = 'Result', value, - extension + extension, + loading, + loadingText }: { title?: string; value: File | null; extension: string; + loading?: boolean; + loadingText?: string; }) { const [preview, setPreview] = React.useState(null); const { showSnackBar } = useContext(CustomSnackBarContext); @@ -59,12 +63,14 @@ export default function ToolFileResult({ } }; + type SupportedFileType = 'image' | 'video' | 'audio' | 'pdf' | 'unknown'; // Determine the file type based on MIME type - const getFileType = () => { + const getFileType = (): SupportedFileType => { if (!value) return 'unknown'; if (value.type.startsWith('image/')) return 'image'; if (value.type.startsWith('video/')) return 'video'; if (value.type.startsWith('audio/')) return 'audio'; + if (value.type.startsWith('application/pdf')) return 'pdf'; return 'unknown'; }; @@ -83,44 +89,70 @@ export default function ToolFileResult({ bgcolor: 'white' }} > - {preview && ( + {loading ? ( - {fileType === 'image' && ( - - )} - {fileType === 'video' && ( - - )} - {fileType === 'audio' && ( - - )} - {fileType === 'unknown' && ( - - File processed successfully. Click download to save the result. - - )} + + + {loadingText}... This may take a moment. + + ) : ( + preview && ( + + {fileType === 'image' && ( + + )} + {fileType === 'video' && ( + + )} + {fileType === 'audio' && ( + + )} + {fileType === 'pdf' && ( + + )} + {fileType === 'unknown' && ( + + File processed successfully. Click download to save the + result. + + )} + + ) )} ) => ( - (null); + const [result, setResult] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + + const compute = async (_optionsValues: typeof initialValues, input: any) => { + if (!input) return; + + setIsProcessing(true); + + try { + // Convert the input file to a Blob URL + const inputUrl = URL.createObjectURL(input); + + // Process the image with the background removal library + const blob = await removeBackground(inputUrl, { + progress: (progress) => { + console.log(`Background removal progress: ${progress}`); + } + }); + + // Create a new file from the blob + const newFile = new File( + [blob], + input.name.replace(/\.[^/.]+$/, '') + '-no-bg.png', + { + type: 'image/png' + } + ); + + setResult(newFile); + } catch (err) { + console.error('Error removing background:', err); + throw new Error( + 'Failed to remove background. Please try a different image or try again later.' + ); + } finally { + setIsProcessing(false); + } + }; + + return ( + + } + resultComponent={ + + } + toolInfo={{ + title: 'Remove Background from PNG', + description: + 'This tool uses AI to automatically remove the background from your images, creating a transparent PNG. Perfect for product photos, profile pictures, and design assets.' + }} + /> + ); +} diff --git a/src/pages/tools/image/png/remove-background/meta.ts b/src/pages/tools/image/png/remove-background/meta.ts new file mode 100644 index 0000000..1ae19e7 --- /dev/null +++ b/src/pages/tools/image/png/remove-background/meta.ts @@ -0,0 +1,13 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('png', { + name: 'Remove Background from PNG', + path: 'remove-background', + icon: 'mdi:image-remove', + description: + "World's simplest online tool to remove backgrounds from PNG images. Just upload your image and our AI-powered tool will automatically remove the background, giving you a transparent PNG. Perfect for product photos, profile pictures, and design assets.", + shortDescription: 'Automatically remove backgrounds from images', + keywords: ['remove', 'background', 'png', 'transparent', 'image', 'ai'], + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/pdf/index.ts b/src/pages/tools/pdf/index.ts new file mode 100644 index 0000000..e93a8c1 --- /dev/null +++ b/src/pages/tools/pdf/index.ts @@ -0,0 +1,4 @@ +import { meta as splitPdfMeta } from './split-pdf/meta'; +import { DefinedTool } from '@tools/defineTool'; + +export const pdfTools: DefinedTool[] = [splitPdfMeta]; diff --git a/src/pages/tools/pdf/split-pdf/index.tsx b/src/pages/tools/pdf/split-pdf/index.tsx new file mode 100644 index 0000000..2682949 --- /dev/null +++ b/src/pages/tools/pdf/split-pdf/index.tsx @@ -0,0 +1,181 @@ +import { Box, Typography } from '@mui/material'; +import React, { useEffect, useRef, useState } from 'react'; +import ToolFileInput from '@components/input/ToolFileInput'; +import ToolFileResult from '@components/result/ToolFileResult'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import { parsePageRanges, splitPdf } from './service'; +import { CardExampleType } from '@components/examples/ToolExamples'; +import { PDFDocument } from 'pdf-lib'; +import { FormikProps } from 'formik'; +import ToolPdfInput from '@components/input/ToolPdfInput'; + +type InitialValuesType = { + pageRanges: string; +}; + +const initialValues: InitialValuesType = { + pageRanges: '' +}; + +const exampleCards: CardExampleType[] = [ + { + title: 'Extract Specific Pages', + description: 'Extract pages 1, 5, 6, 7, and 8 from a PDF document.', + sampleText: '', + sampleResult: '', + sampleOptions: { + pageRanges: '1,5-8' + } + }, + { + title: 'Extract First and Last Pages', + description: 'Extract only the first and last pages from a PDF document.', + sampleText: '', + sampleResult: '', + sampleOptions: { + pageRanges: '1,10' + } + }, + { + title: 'Extract a Range of Pages', + description: 'Extract a continuous range of pages from a PDF document.', + sampleText: '', + sampleResult: '', + sampleOptions: { + pageRanges: '3-7' + } + } +]; + +export default function SplitPdf({ title }: ToolComponentProps) { + const [input, setInput] = useState(null); + const [result, setResult] = useState(null); + const [isProcessing, setIsProcessing] = useState(false); + const [totalPages, setTotalPages] = useState(0); + const [pageRangePreview, setPageRangePreview] = useState(''); + + // Get the total number of pages when a PDF is uploaded + useEffect(() => { + const getPdfInfo = async () => { + if (!input) { + setTotalPages(0); + return; + } + + try { + const arrayBuffer = await input.arrayBuffer(); + const pdf = await PDFDocument.load(arrayBuffer); + setTotalPages(pdf.getPageCount()); + } catch (error) { + console.error('Error getting PDF info:', error); + setTotalPages(0); + } + }; + + getPdfInfo(); + }, [input]); + + const onValuesChange = (values: InitialValuesType) => { + const { pageRanges } = values; + if (!totalPages || !pageRanges?.trim()) { + setPageRangePreview(''); + return; + } + try { + const count = parsePageRanges(pageRanges, totalPages).length; + setPageRangePreview( + `${count} page${count !== 1 ? 's' : ''} will be extracted` + ); + } catch (error) { + setPageRangePreview(''); + } + }; + + const compute = async (values: InitialValuesType, input: File | null) => { + if (!input) return; + + try { + setIsProcessing(true); + const splitResult = await splitPdf(input, values.pageRanges); + setResult(splitResult); + } catch (error) { + throw new Error('Error splitting PDF:' + error); + } finally { + setIsProcessing(false); + } + }; + + return ( + + } + resultComponent={ + + } + getGroups={({ values, updateField }) => [ + { + title: 'Page Selection', + component: ( + + {totalPages > 0 && ( + + PDF has {totalPages} page{totalPages !== 1 ? 's' : ''} + + )} + { + updateField('pageRanges', val); + }} + description={ + 'Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)' + } + placeholder={'e.g., 1,5-8'} + /> + {pageRangePreview && ( + + {pageRangePreview} + + )} + + ) + } + ]} + onValuesChange={onValuesChange} + toolInfo={{ + title: 'How to Use the Split PDF Tool', + description: `This tool allows you to extract specific pages from a PDF document. You can specify individual page numbers (e.g., 1,3,5) or page ranges (e.g., 2-6) or a combination of both (e.g., 1,3-5,8). + +Leave the page ranges field empty to include all pages from the PDF. + +Examples: +- "1,5,9" extracts pages 1, 5, and 9 +- "1-5" extracts pages 1 through 5 +- "1,3-5,8-10" extracts pages 1, 3, 4, 5, 8, 9, and 10` + }} + /> + ); +} diff --git a/src/pages/tools/pdf/split-pdf/meta.ts b/src/pages/tools/pdf/split-pdf/meta.ts new file mode 100644 index 0000000..9b5d033 --- /dev/null +++ b/src/pages/tools/pdf/split-pdf/meta.ts @@ -0,0 +1,13 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const meta = defineTool('pdf', { + name: 'Split PDF', + shortDescription: 'Extract specific pages from a PDF file', + description: + 'Extract specific pages from a PDF file using page numbers or ranges (e.g., 1,5-8)', + icon: 'mdi:file-pdf-box', + component: lazy(() => import('./index')), + keywords: ['pdf', 'split', 'extract', 'pages', 'range', 'document'], + path: 'split-pdf' +}); diff --git a/src/pages/tools/pdf/split-pdf/service.test.ts b/src/pages/tools/pdf/split-pdf/service.test.ts new file mode 100644 index 0000000..44cf15a --- /dev/null +++ b/src/pages/tools/pdf/split-pdf/service.test.ts @@ -0,0 +1,43 @@ +import { parsePageRanges } from './service'; + +describe('parsePageRanges', () => { + test('should return all pages when input is empty', () => { + expect(parsePageRanges('', 5)).toEqual([1, 2, 3, 4, 5]); + }); + + test('should parse single page numbers', () => { + expect(parsePageRanges('1,3,5', 5)).toEqual([1, 3, 5]); + }); + + test('should parse page ranges', () => { + expect(parsePageRanges('2-4', 5)).toEqual([2, 3, 4]); + }); + + test('should parse mixed page numbers and ranges', () => { + expect(parsePageRanges('1,3-5', 5)).toEqual([1, 3, 4, 5]); + }); + + test('should handle whitespace', () => { + expect(parsePageRanges(' 1, 3 - 5 ', 5)).toEqual([1, 3, 4, 5]); + }); + + test('should ignore invalid page numbers', () => { + expect(parsePageRanges('1,a,3', 5)).toEqual([1, 3]); + }); + + test('should ignore out-of-range page numbers', () => { + expect(parsePageRanges('1,6,3', 5)).toEqual([1, 3]); + }); + + test('should limit ranges to valid pages', () => { + expect(parsePageRanges('0-6', 5)).toEqual([1, 2, 3, 4, 5]); + }); + + test('should handle reversed ranges', () => { + expect(parsePageRanges('4-2', 5)).toEqual([2, 3, 4]); + }); + + test('should remove duplicates', () => { + expect(parsePageRanges('1,1,2,2-4,3', 5)).toEqual([1, 2, 3, 4]); + }); +}); diff --git a/src/pages/tools/pdf/split-pdf/service.ts b/src/pages/tools/pdf/split-pdf/service.ts new file mode 100644 index 0000000..7c9dfae --- /dev/null +++ b/src/pages/tools/pdf/split-pdf/service.ts @@ -0,0 +1,74 @@ +import { PDFDocument } from 'pdf-lib'; + +/** + * Parses a page range string and returns an array of page numbers + * @param pageRangeStr String like "1,3-5,7" to extract pages 1, 3, 4, 5, and 7 + * @param totalPages Total number of pages in the PDF + * @returns Array of page numbers to extract + */ +export function parsePageRanges( + pageRangeStr: string, + totalPages: number +): number[] { + if (!pageRangeStr.trim()) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + const pageNumbers = new Set(); + const ranges = pageRangeStr.split(','); + + for (const range of ranges) { + const trimmedRange = range.trim(); + + if (trimmedRange.includes('-')) { + const [start, end] = trimmedRange.split('-').map(Number); + if (!isNaN(start) && !isNaN(end)) { + // Handle both forward and reversed ranges + const normalizedStart = Math.min(start, end); + const normalizedEnd = Math.max(start, end); + + for ( + let i = Math.max(1, normalizedStart); + i <= Math.min(totalPages, normalizedEnd); + i++ + ) { + pageNumbers.add(i); + } + } + } else { + const pageNum = parseInt(trimmedRange, 10); + if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) { + pageNumbers.add(pageNum); + } + } + } + + return [...pageNumbers].sort((a, b) => a - b); +} + +/** + * Splits a PDF file based on specified page ranges + * @param pdfFile The input PDF file + * @param pageRanges String specifying which pages to extract (e.g., "1,3-5,7") + * @returns Promise resolving to a new PDF file with only the selected pages + */ +export async function splitPdf( + pdfFile: File, + pageRanges: string +): Promise { + const arrayBuffer = await pdfFile.arrayBuffer(); + const sourcePdf = await PDFDocument.load(arrayBuffer); + const totalPages = sourcePdf.getPageCount(); + const pagesToExtract = parsePageRanges(pageRanges, totalPages); + + const newPdf = await PDFDocument.create(); + const copiedPages = await newPdf.copyPages( + sourcePdf, + pagesToExtract.map((pageNum) => pageNum - 1) + ); + copiedPages.forEach((page) => newPdf.addPage(page)); + + const newPdfBytes = await newPdf.save(); + const newFileName = pdfFile.name.replace('.pdf', '-extracted.pdf'); + return new File([newPdfBytes], newFileName, { type: 'application/pdf' }); +} diff --git a/src/pages/tools/time/convert-hours-to-days/convert-hours-to-days.service.test.ts b/src/pages/tools/time/convert-hours-to-days/convert-hours-to-days.service.test.ts new file mode 100644 index 0000000..d830723 --- /dev/null +++ b/src/pages/tools/time/convert-hours-to-days/convert-hours-to-days.service.test.ts @@ -0,0 +1,40 @@ +import { expect, describe, it } from 'vitest'; +import { convertHoursToDays } from './service'; + +describe('convertHoursToDays', () => { + it('should convert hours to days with default accuracy', () => { + const input = '48'; + const result = convertHoursToDays(input, '1', false); + expect(result).toBe('2'); + }); + + it('should convert hours to days with specified accuracy', () => { + const input = '50'; + const result = convertHoursToDays(input, '2', false); + expect(result).toBe('2.08'); + }); + + it('should append "days" postfix when daysFlag is true', () => { + const input = '72'; + const result = convertHoursToDays(input, '1', true); + expect(result).toBe('3 days'); + }); + + it('should handle multiple lines of input', () => { + const input = '24\n48\n72'; + const result = convertHoursToDays(input, '1', true); + expect(result).toBe('1 days\n2 days\n3 days'); + }); + + it('should handle invalid input gracefully', () => { + const input = 'abc'; + const result = convertHoursToDays(input, '1', false); + expect(result).toBe(''); + }); + + it('should handle empty input', () => { + const input = ''; + const result = convertHoursToDays(input, '1', false); + expect(result).toBe(''); + }); +}); diff --git a/src/pages/tools/time/convert-hours-to-days/index.tsx b/src/pages/tools/time/convert-hours-to-days/index.tsx new file mode 100644 index 0000000..1b6d3c1 --- /dev/null +++ b/src/pages/tools/time/convert-hours-to-days/index.tsx @@ -0,0 +1,136 @@ +import { Box } from '@mui/material'; +import React, { useState } from 'react'; +import ToolContent from '@components/ToolContent'; +import { ToolComponentProps } from '@tools/defineTool'; +import ToolTextInput from '@components/input/ToolTextInput'; +import ToolTextResult from '@components/result/ToolTextResult'; +import { GetGroupsType } from '@components/options/ToolOptions'; +import { CardExampleType } from '@components/examples/ToolExamples'; +import CheckboxWithDesc from '@components/options/CheckboxWithDesc'; +import TextFieldWithDesc from '@components/options/TextFieldWithDesc'; +import { convertHoursToDays } from './service'; + +const initialValues = { + daysFlag: false, + accuracy: '1' +}; +type InitialValuesType = typeof initialValues; +const exampleCards: CardExampleType[] = [ + { + title: 'Hours to Integer Days', + description: + 'In this example, we convert ten hour values to ten day values. Each input hour is divisible by 24 without a remainder, so all converted output values are full days. To better communicate the time units, we use the word "hours" in the input data and also add the word "days" to the output data.', + sampleText: `24 hours +48 hours +72 hours +96 hours +120 hours +144 hours +168 hours +192 hours +216 hours +240 hours`, + sampleResult: `1 day +2 days +3 days +4 days +5 days +6 days +7 days +8 days +9 days +10 days`, + sampleOptions: { daysFlag: true, accuracy: '2' } + }, + { + title: 'Decimal Days', + description: + 'In this example, we convert five decimal fraction day values to hours. Conversion of partial days is similar to the conversion of full days – they are all multiplied by 24. We turn off the option that appends the "hours" string after the converted values and get only the numerical hour values in the output.', + sampleText: `1 hr +100 hr +9999 hr +12345 hr +333333 hr`, + sampleResult: `0.0417 days +4.1667 days +416.625 days +514.375 days +13888.875 days`, + sampleOptions: { daysFlag: true, accuracy: '4' } + }, + { + title: 'Partial Hours', + description: + 'In the modern Gregorian calendar, a common year has 365 days and a leap year has 366 days. This makes the true average length of a year to be 365.242199 days. In this example, we load this number in the input field and convert it to the hours. It turns out that there 8765.812776 hours in an average year.', + sampleText: `0.5 +0.01 +0.99`, + sampleResult: `0.02083333 +0.00041667 +0.04125`, + sampleOptions: { daysFlag: false, accuracy: '8' } + } +]; + +export default function ConvertDaysToHours({ + title, + longDescription +}: ToolComponentProps) { + const [input, setInput] = useState(''); + const [result, setResult] = useState(''); + + const compute = (optionsValues: typeof initialValues, input: string) => { + setResult( + convertHoursToDays(input, optionsValues.accuracy, optionsValues.daysFlag) + ); + }; + + const getGroups: GetGroupsType | null = ({ + values, + updateField + }) => [ + { + title: 'Day Value Accuracy', + component: ( + + updateField('accuracy', val)} + type={'text'} + /> + + ) + }, + { + title: 'Days Postfix', + component: ( + + updateField('daysFlag', val)} + checked={values.daysFlag} + title={'Append Days Postfix'} + description={'Display numeric day values with the postfix "days".'} + /> + + ) + } + ]; + + 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/time/convert-hours-to-days/meta.ts b/src/pages/tools/time/convert-hours-to-days/meta.ts new file mode 100644 index 0000000..28ed2cc --- /dev/null +++ b/src/pages/tools/time/convert-hours-to-days/meta.ts @@ -0,0 +1,15 @@ +import { defineTool } from '@tools/defineTool'; +import { lazy } from 'react'; + +export const tool = defineTool('time', { + path: 'convert-hours-to-days', + name: 'Convert Hours to Days', + icon: 'mdi:hours-24', + description: + 'With this browser-based application, you can calculate how many days there are in the given number of hours. Given one or more hour values in the input, it converts them into days via the simple math formula: days = hours/24. It works with arbitrary large hour values and you can also customize the decimal day precision.', + shortDescription: 'Convert hours to days easily.', + keywords: ['convert', 'hours', 'days'], + longDescription: + "This is a quick online utility for converting hours to days. To figure out the number of days in the specified hours, the program divides them by 24. For example, if the input hours value is 48, then by doing 48/24, it finds that there are 2 days, or if the hours value is 120, then it's 120/24 = 5 days. If the hours value is not divisible by 24, then the number of days is displayed as a decimal number. For example, 36 hours is 36/24 = 1.5 days and 100 hours is approximately 4.167 days. You can specify the precision of the decimal fraction calculation in the options. You can also enable the option that adds the postfix 'days' to all the output values. Timeabulous!", + component: lazy(() => import('./index')) +}); diff --git a/src/pages/tools/time/convert-hours-to-days/service.ts b/src/pages/tools/time/convert-hours-to-days/service.ts new file mode 100644 index 0000000..7448672 --- /dev/null +++ b/src/pages/tools/time/convert-hours-to-days/service.ts @@ -0,0 +1,33 @@ +import { containsOnlyDigits } from '@utils/string'; + +function compute(input: string, accuracy: number) { + if (!containsOnlyDigits(input)) { + return ''; + } + const hours = parseFloat(input); + const days = (hours / 24).toFixed(accuracy); + return parseFloat(days); +} + +export function convertHoursToDays( + input: string, + accuracy: string, + daysFlag: boolean +): string { + if (!containsOnlyDigits(accuracy)) { + throw new Error('Accuracy contains non digits.'); + } + + const result: string[] = []; + + const lines = input.split('\n'); + + lines.forEach((line) => { + const parts = line.split(' '); + const hours = parts[0]; // Extract the number before the space + const days = compute(hours, Number(accuracy)); + result.push(daysFlag ? `${days} days` : `${days}`); + }); + + return result.join('\n'); +} diff --git a/src/pages/tools/time/index.ts b/src/pages/tools/time/index.ts index 5e43459..f7e7f9f 100644 --- a/src/pages/tools/time/index.ts +++ b/src/pages/tools/time/index.ts @@ -1,3 +1,4 @@ import { tool as daysDoHours } from './convert-days-to-hours/meta'; +import { tool as hoursToDays } from './convert-hours-to-days/meta'; -export const timeTools = [daysDoHours]; +export const timeTools = [daysDoHours, hoursToDays]; diff --git a/src/pages/tools/video/trim/index.tsx b/src/pages/tools/video/trim/index.tsx index c2538ba..629d2d9 100644 --- a/src/pages/tools/video/trim/index.tsx +++ b/src/pages/tools/video/trim/index.tsx @@ -1,7 +1,6 @@ import { Box } from '@mui/material'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import * as Yup from 'yup'; -import ToolFileInput from '@components/input/ToolFileInput'; import ToolFileResult from '@components/result/ToolFileResult'; import ToolContent from '@components/ToolContent'; import { ToolComponentProps } from '@tools/defineTool'; @@ -11,6 +10,7 @@ import { updateNumberField } from '@utils/string'; import { FFmpeg } from '@ffmpeg/ffmpeg'; import { fetchFile } from '@ffmpeg/util'; import { debounce } from 'lodash'; +import ToolVideoInput from '@components/input/ToolVideoInput'; const ffmpeg = new FFmpeg(); @@ -35,14 +35,16 @@ export default function TrimVideo({ title }: ToolComponentProps) { optionsValues: typeof initialValues, input: File | null ) => { - console.log('compute', optionsValues, input); if (!input) return; const { trimStart, trimEnd } = optionsValues; try { if (!ffmpeg.loaded) { - await ffmpeg.load(); + await ffmpeg.load({ + wasmURL: + 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm' + }); } const inputName = 'input.mp4'; @@ -111,12 +113,11 @@ export default function TrimVideo({ title }: ToolComponentProps) { input={input} renderCustomInput={({ trimStart, trimEnd }, setFieldValue) => { return ( - { setFieldValue('trimStart', trimStart); diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx index 0ba3a66..e32b940 100644 --- a/src/tools/defineTool.tsx +++ b/src/tools/defineTool.tsx @@ -22,7 +22,8 @@ export type ToolCategory = | 'list' | 'json' | 'csv' - | 'time'; + | 'time' + | 'pdf'; export interface DefinedTool { type: ToolCategory; diff --git a/src/tools/index.ts b/src/tools/index.ts index e3156c9..57596f3 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -10,6 +10,7 @@ import { jsonTools } from '../pages/tools/json'; import { csvTools } from '../pages/tools/csv'; import { timeTools } from '../pages/tools/time'; import { IconifyIcon } from '@iconify/react'; +import { pdfTools } from '../pages/tools/pdf'; export const tools: DefinedTool[] = [ ...imageTools, @@ -19,7 +20,8 @@ export const tools: DefinedTool[] = [ ...csvTools, ...videoTools, ...numberTools, - ...timeTools + ...timeTools, + ...pdfTools ]; const categoriesConfig: { type: ToolCategory; @@ -76,6 +78,12 @@ const categoriesConfig: { value: 'Tools for working with videos – extract frames from videos, create GIFs from videos, convert videos to different formats, and much more.' }, + { + type: 'pdf', + icon: 'tabler:pdf', + value: + 'Tools for working with PDF files - extract text from PDFs, convert PDFs to other formats, manipulate PDFs, and much more.' + }, { type: 'time', icon: 'fluent-mdl2:date-time',