mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-10-11 16:59:34 +02:00
Merge branch 'main' into chesterkxng
This commit is contained in:
213
.idea/workspace.xml
generated
213
.idea/workspace.xml
generated
@@ -4,11 +4,33 @@
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="refactor: time between dates">
|
||||
<change afterPath="$PROJECT_DIR$/src/components/BackButton.tsx" afterDir="false" />
|
||||
<list default="true" id="b30e2810-c4c1-4aad-b134-794e52cc1c7d" name="Changes" comment="chore: compress video icon">
|
||||
<change afterPath="$PROJECT_DIR$/@types/theme.d.ts" afterDir="false" />
|
||||
<change afterPath="$PROJECT_DIR$/public/assets/background-dark.png" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/App.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/App.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/Hero.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/Hero.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/Navbar/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/Navbar/index.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/ToolHeader.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolHeader.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/ToolLayout.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/ToolLayout.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/allTools/ToolCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/allTools/ToolCard.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/examples/ExampleCard.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/examples/ExampleCard.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/BaseFileInput.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/input/ToolFileInput.tsx" beforeDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/input/ToolTextInput.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/input/ToolTextInput.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/options/ColorSelector.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/ColorSelector.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/options/TextFieldWithDesc.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/TextFieldWithDesc.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/options/ToolOptions.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolFileResult.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/components/result/ToolTextResult.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/components/result/ToolTextResult.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/config/muiConfig.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/config/muiConfig.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/home/Categories.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/home/Categories.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/home/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/home/index.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools-by-category/index.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/rotate/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/rotate/index.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/pdf/split-pdf/index.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/compress/service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/compress/service.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/src/pages/tools/video/gif/change-speed/index.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/pages/tools/video/gif/change-speed/index.tsx" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/tsconfig.json" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
@@ -25,7 +47,7 @@
|
||||
<option name="PUSH_AUTO_UPDATE" value="true" />
|
||||
<option name="RECENT_BRANCH_BY_REPOSITORY">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$" value="fork/lfsjesus/feature/rotate-video" />
|
||||
<entry key="$PROJECT_DIR$" value="main" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
@@ -108,6 +130,20 @@
|
||||
"number": 73
|
||||
},
|
||||
"lastSeen": 1743265865001
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6Qp5nI",
|
||||
"number": 72
|
||||
},
|
||||
"lastSeen": 1743338472110
|
||||
},
|
||||
{
|
||||
"id": {
|
||||
"id": "PR_kwDOMJIfts6QsjlS",
|
||||
"number": 76
|
||||
},
|
||||
"lastSeen": 1743352150953
|
||||
}
|
||||
]
|
||||
}]]></component>
|
||||
@@ -139,56 +175,56 @@
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"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"
|
||||
<component name="PropertiesComponent"><![CDATA[{
|
||||
"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": "dark-mode",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "C:/Users/Ibrahima/IdeaProjects/omni-tools/@types",
|
||||
"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"
|
||||
}
|
||||
}</component>
|
||||
}]]></component>
|
||||
<component name="ReactDesignerToolWindowState">
|
||||
<option name="myId2Visible">
|
||||
<map>
|
||||
@@ -200,21 +236,21 @@
|
||||
</component>
|
||||
<component name="RecentsManager">
|
||||
<key name="CopyFile.RECENT_KEYS">
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\@types" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\components\input" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\.husky" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\assets" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\.github" />
|
||||
</key>
|
||||
<key name="MoveFile.RECENT_KEYS">
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\@types" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\public\assets" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\tools" />
|
||||
<recent name="C:\Users\Ibrahima\IdeaProjects\omni-tools\src\pages\categories" />
|
||||
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components" />
|
||||
<recent name="C:\Users\HP\IdeaProjects\omni-tools\src\components\options" />
|
||||
</key>
|
||||
</component>
|
||||
<component name="RunManager" selected="Vitest.calculateTimeBetweenDates">
|
||||
<component name="RunManager" selected="npm.dev">
|
||||
<configuration name="calculateTimeBetweenDates" type="JavaScriptTestRunnerVitest" temporary="true" nameIsGenerated="true">
|
||||
<node-interpreter value="project" />
|
||||
<vitest-package value="$PROJECT_DIR$/node_modules/vitest" />
|
||||
@@ -293,11 +329,11 @@
|
||||
</list>
|
||||
<recent_temporary>
|
||||
<list>
|
||||
<item itemvalue="Vitest.calculateTimeBetweenDates" />
|
||||
<item itemvalue="Vitest.timeBetweenDates" />
|
||||
<item itemvalue="npm.dev" />
|
||||
<item itemvalue="Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp" />
|
||||
<item itemvalue="Vitest.parsePageRanges" />
|
||||
<item itemvalue="Vitest.timeBetweenDates" />
|
||||
<item itemvalue="Vitest.calculateTimeBetweenDates" />
|
||||
</list>
|
||||
</recent_temporary>
|
||||
</component>
|
||||
@@ -384,30 +420,7 @@
|
||||
<workItem from="1743018497879" duration="3895000" />
|
||||
<workItem from="1743047367993" duration="986000" />
|
||||
<workItem from="1743103182313" duration="4264000" />
|
||||
</task>
|
||||
<task id="LOCAL-00124" summary="docs: readme">
|
||||
<option name="closed" value="true" />
|
||||
<created>1740614012237</created>
|
||||
<option name="number" value="00124" />
|
||||
<option name="presentableId" value="LOCAL-00124" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1740614012237</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00125" summary="docs: readme">
|
||||
<option name="closed" value="true" />
|
||||
<created>1740614185980</created>
|
||||
<option name="number" value="00125" />
|
||||
<option name="presentableId" value="LOCAL-00125" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1740614185980</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00126" summary="chore: handle enter press on search">
|
||||
<option name="closed" value="true" />
|
||||
<created>1740614957672</created>
|
||||
<option name="number" value="00126" />
|
||||
<option name="presentableId" value="LOCAL-00126" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1740614957672</updated>
|
||||
<workItem from="1743348610793" duration="21855000" />
|
||||
</task>
|
||||
<task id="LOCAL-00127" summary="chore: show tooloptions in example">
|
||||
<option name="closed" value="true" />
|
||||
@@ -777,7 +790,31 @@
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743106796406</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="173" />
|
||||
<task id="LOCAL-00173" summary="fix: typos">
|
||||
<option name="closed" value="true" />
|
||||
<created>1743349732644</created>
|
||||
<option name="number" value="00173" />
|
||||
<option name="presentableId" value="LOCAL-00173" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743349732644</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00174" summary="feat: compress video">
|
||||
<option name="closed" value="true" />
|
||||
<created>1743355099396</created>
|
||||
<option name="number" value="00174" />
|
||||
<option name="presentableId" value="LOCAL-00174" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743355099396</updated>
|
||||
</task>
|
||||
<task id="LOCAL-00175" summary="chore: compress video icon">
|
||||
<option name="closed" value="true" />
|
||||
<created>1743355166425</created>
|
||||
<option name="number" value="00175" />
|
||||
<option name="presentableId" value="LOCAL-00175" />
|
||||
<option name="project" value="LOCAL" />
|
||||
<updated>1743355166426</updated>
|
||||
</task>
|
||||
<option name="localTasksCounter" value="176" />
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
@@ -824,9 +861,6 @@
|
||||
<option name="CHECK_CODE_SMELLS_BEFORE_PROJECT_COMMIT" value="false" />
|
||||
<option name="CHECK_NEW_TODO" value="false" />
|
||||
<option name="ADD_EXTERNAL_FILES_SILENTLY" value="true" />
|
||||
<MESSAGE value="feat: minify json" />
|
||||
<MESSAGE value="feat: stringify json" />
|
||||
<MESSAGE value="feat: arithmetic sequence" />
|
||||
<MESSAGE value="style: tools height" />
|
||||
<MESSAGE value="chore: update meta" />
|
||||
<MESSAGE value="feat: change pgn opacity" />
|
||||
@@ -849,7 +883,10 @@
|
||||
<MESSAGE value="chore: show new tools in landing" />
|
||||
<MESSAGE value="chore: zoom on hover" />
|
||||
<MESSAGE value="refactor: time between dates" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="refactor: time between dates" />
|
||||
<MESSAGE value="fix: typos" />
|
||||
<MESSAGE value="feat: compress video" />
|
||||
<MESSAGE value="chore: compress video icon" />
|
||||
<option name="LAST_COMMIT_MESSAGE" value="chore: compress video icon" />
|
||||
</component>
|
||||
<component name="XSLT-Support.FileAssociations.UIState">
|
||||
<expand />
|
||||
|
9
@types/theme.d.ts
vendored
Normal file
9
@types/theme.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import '@mui/material/styles';
|
||||
|
||||
declare module '@mui/material/styles' {
|
||||
interface TypeBackground {
|
||||
hover?: string;
|
||||
lightSecondary?: string;
|
||||
darkSecondary?: string;
|
||||
}
|
||||
}
|
@@ -68,7 +68,7 @@ We strive to offer a variety of tools, including:
|
||||
## **Miscellaneous Tools**
|
||||
|
||||
- JSON Tools
|
||||
- XML Tools
|
||||
- PDF Tools
|
||||
- CSV Tools
|
||||
- And more...
|
||||
|
||||
|
BIN
public/assets/background-dark.png
Normal file
BIN
public/assets/background-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 140 KiB |
@@ -1,14 +1,14 @@
|
||||
import { BrowserRouter, useRoutes } from 'react-router-dom';
|
||||
import routesConfig from '../config/routesConfig';
|
||||
import Navbar from './Navbar';
|
||||
import { Suspense } from 'react';
|
||||
import { Suspense, useMemo, useState } from 'react';
|
||||
import Loading from './Loading';
|
||||
import { ThemeProvider } from '@mui/material';
|
||||
import theme from '../config/muiConfig';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { CustomSnackBarProvider } from '../contexts/CustomSnackBarContext';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import { tools } from '../tools';
|
||||
import './index.css';
|
||||
import { darkTheme, lightTheme } from '../config/muiConfig';
|
||||
|
||||
const AppRoutes = () => {
|
||||
const updatedRoutesConfig = [...routesConfig];
|
||||
@@ -19,21 +19,29 @@ const AppRoutes = () => {
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [darkMode, setDarkMode] = useState<boolean>(() => {
|
||||
return localStorage.getItem('theme') === 'dark';
|
||||
});
|
||||
const theme = useMemo(() => (darkMode ? darkTheme : lightTheme), [darkMode]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<SnackbarProvider
|
||||
maxSnack={5}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right'
|
||||
}}
|
||||
classes={{
|
||||
containerRoot: 'bottom-0 right-0 mb-52 md:mb-68 mr-8 lg:mr-80 z-99'
|
||||
}}
|
||||
>
|
||||
<CustomSnackBarProvider>
|
||||
<BrowserRouter>
|
||||
<Navbar />
|
||||
<Navbar
|
||||
onSwitchTheme={() => {
|
||||
setDarkMode((prevState) => !prevState);
|
||||
localStorage.setItem('theme', darkMode ? 'light' : 'dark');
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Autocomplete, Box, Stack, TextField } from '@mui/material';
|
||||
import { Autocomplete, Box, Stack, TextField, useTheme } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Grid from '@mui/material/Grid';
|
||||
@@ -25,6 +25,7 @@ const exampleTools: { label: string; url: string }[] = [
|
||||
];
|
||||
export default function Hero() {
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const theme = useTheme();
|
||||
const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(
|
||||
_.shuffle(tools)
|
||||
);
|
||||
@@ -78,7 +79,7 @@ export default function Hero() {
|
||||
endAdornment: <SearchIcon />,
|
||||
sx: {
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'white'
|
||||
backgroundColor: 'background.paper'
|
||||
}
|
||||
}}
|
||||
onChange={(event) => handleInputChange(event, event.target.value)}
|
||||
@@ -125,11 +126,13 @@ export default function Hero() {
|
||||
borderWidth: 1,
|
||||
padding: 1,
|
||||
borderRadius: 3,
|
||||
borderColor: 'grey',
|
||||
borderColor: theme.palette.mode === 'dark' ? '#363b41' : 'grey',
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: 'background.paper',
|
||||
cursor: 'pointer',
|
||||
'&:hover': { backgroundColor: '#FAFAFD' }
|
||||
'&:hover': {
|
||||
backgroundColor: 'background.hover'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography>{tool.label}</Typography>
|
||||
|
@@ -17,8 +17,13 @@ import {
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Icon } from '@iconify/react';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
interface NavbarProps {
|
||||
onSwitchTheme: () => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ onSwitchTheme }) => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
@@ -30,7 +35,9 @@ const Navbar: React.FC = () => {
|
||||
// { label: 'Features', path: '/features' }
|
||||
// { label: 'About Us', path: '/about-us' }
|
||||
];
|
||||
|
||||
const buttons: ReactNode[] = [
|
||||
<DarkModeIcon onClick={onSwitchTheme} style={{ cursor: 'pointer' }} />,
|
||||
<Icon
|
||||
onClick={() => window.open('https://discord.gg/SDbbn3hT4b', '_blank')}
|
||||
style={{ cursor: 'pointer' }}
|
||||
@@ -81,9 +88,10 @@ const Navbar: React.FC = () => {
|
||||
return (
|
||||
<AppBar
|
||||
position="static"
|
||||
style={{
|
||||
backgroundColor: '#F5F5FA',
|
||||
color: 'black'
|
||||
sx={{
|
||||
background: 'transparent',
|
||||
boxShadow: 'none',
|
||||
color: 'text.primary'
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
|
@@ -31,7 +31,7 @@ function ToolLinks() {
|
||||
<Grid container spacing={2} mt={1}>
|
||||
<Grid item md={12} lg={6}>
|
||||
<StyledButton
|
||||
sx={{ backgroundColor: 'white' }}
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => scrollToElement('tool')}
|
||||
@@ -43,6 +43,7 @@ function ToolLinks() {
|
||||
<StyledButton
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
onClick={() => scrollToElement('examples')}
|
||||
>
|
||||
See Examples
|
||||
|
@@ -38,7 +38,7 @@ export default function ToolLayout({
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
alignItems={'center'}
|
||||
sx={{ backgroundColor: '#F5F5FA' }}
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
>
|
||||
<Helmet>
|
||||
<title>{`${title} - Omni Tools`}</title>
|
||||
|
@@ -1,4 +1,12 @@
|
||||
import { Box, Card, CardContent, Link, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Link,
|
||||
Stack,
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { ToolCardProps } from './AllTools';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -10,6 +18,7 @@ export default function ToolCard({
|
||||
link,
|
||||
icon
|
||||
}: ToolCardProps) {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Card
|
||||
@@ -17,10 +26,13 @@ export default function ToolCard({
|
||||
raised
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
bgcolor: '#5581b5',
|
||||
borderColor: '#5581b5',
|
||||
bgcolor: 'background.darkSecondary',
|
||||
borderColor: 'background.darkSecondary',
|
||||
color: '#fff',
|
||||
boxShadow: '6px 6px 12px #b8b9be, -6px -6px 12px #fff',
|
||||
boxShadow:
|
||||
theme.palette.mode === 'dark'
|
||||
? null
|
||||
: '6px 6px 12px #b8b9be, -6px -6px 12px #fff',
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
'&:hover': {
|
||||
|
@@ -38,14 +38,16 @@ export default function ExampleCard<T>({
|
||||
changeInputResult(sampleText, sampleOptions);
|
||||
}}
|
||||
sx={{
|
||||
bgcolor: theme.palette.background.default,
|
||||
bgcolor: 'background.lightSecondary',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 2,
|
||||
transition: 'background-color 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
boxShadow: '12px 9px 11px 2px #b8b9be, -6px -6px 12px #fff'
|
||||
boxShadow: `12px 9px 11px 2px ${
|
||||
theme.palette.mode === 'dark' ? theme.palette.grey[900] : '#b8b9be'
|
||||
}, -6px -6px 12px ${theme.palette.mode === 'dark' ? 'black' : '#fff'}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@@ -94,7 +94,7 @@ export default function BaseFileInput({
|
||||
border: preview ? 0 : 1,
|
||||
borderRadius: 2,
|
||||
boxShadow: '5',
|
||||
bgcolor: 'white',
|
||||
bgcolor: 'background.paper',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
@@ -106,7 +106,8 @@ export default function BaseFileInput({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: `url(${greyPattern})`,
|
||||
backgroundImage:
|
||||
theme.palette.mode === 'dark' ? null : `url(${greyPattern})`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
@@ -126,7 +127,13 @@ export default function BaseFileInput({
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.grey['600']}>
|
||||
<Typography
|
||||
color={
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey['300']
|
||||
: theme.palette.grey['600']
|
||||
}
|
||||
>
|
||||
Click here to select a {type} from your device, press Ctrl+V to
|
||||
use a {type} from your clipboard, drag and drop a file from
|
||||
desktop
|
||||
|
@@ -1,377 +0,0 @@
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import ReactCrop, { Crop, PixelCrop } from 'react-image-crop';
|
||||
import 'react-image-crop/dist/ReactCrop.css';
|
||||
import InputHeader from '../InputHeader';
|
||||
import InputFooter from './InputFooter';
|
||||
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
|
||||
import greyPattern from '@assets/grey-pattern.png';
|
||||
import { globalInputHeight } from '../../config/uiConfig';
|
||||
import Slider from 'rc-slider';
|
||||
import 'rc-slider/assets/index.css';
|
||||
|
||||
interface ToolFileInputProps {
|
||||
value: File | null;
|
||||
onChange: (file: File) => void;
|
||||
accept: string[];
|
||||
title?: string;
|
||||
showCropOverlay?: boolean;
|
||||
cropShape?: 'rectangular' | 'circular';
|
||||
cropPosition?: { x: number; y: number };
|
||||
cropSize?: { width: number; height: number };
|
||||
onCropChange?: (
|
||||
position: { x: number; y: number },
|
||||
size: { width: number; height: number }
|
||||
) => void;
|
||||
type?: 'image' | 'video' | 'audio';
|
||||
// Video specific props
|
||||
showTrimControls?: boolean;
|
||||
onTrimChange?: (trimStart: number, trimEnd: number) => void;
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
}
|
||||
|
||||
export default function ToolFileInput({
|
||||
value,
|
||||
onChange,
|
||||
accept,
|
||||
title = 'File',
|
||||
showCropOverlay = false,
|
||||
cropShape = 'rectangular',
|
||||
cropPosition = { x: 0, y: 0 },
|
||||
cropSize = { width: 100, height: 100 },
|
||||
onCropChange,
|
||||
type = 'image',
|
||||
showTrimControls = false,
|
||||
onTrimChange,
|
||||
trimStart = 0,
|
||||
trimEnd = 100
|
||||
}: ToolFileInputProps) {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const theme = useTheme();
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [imgWidth, setImgWidth] = useState(0);
|
||||
const [imgHeight, setImgHeight] = useState(0);
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
|
||||
// Convert position and size to crop format used by ReactCrop
|
||||
const [crop, setCrop] = useState<Crop>({
|
||||
unit: 'px',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
});
|
||||
|
||||
const RATIO = imageRef.current ? imgWidth / imageRef.current.width : 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (imgWidth && imgHeight) {
|
||||
setCrop({
|
||||
unit: 'px',
|
||||
x: cropPosition.x / RATIO,
|
||||
y: cropPosition.y / RATIO,
|
||||
width: cropSize.width / RATIO,
|
||||
height: cropSize.height / RATIO
|
||||
});
|
||||
}
|
||||
}, [cropPosition, cropSize, imgWidth, imgHeight]);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (value) {
|
||||
const blob = new Blob([value], { type: value.type });
|
||||
const clipboardItem = new ClipboardItem({ [value.type]: blob });
|
||||
|
||||
navigator.clipboard
|
||||
.write([clipboardItem])
|
||||
.then(() => showSnackBar('File copied', 'success'))
|
||||
.catch((err) => {
|
||||
showSnackBar('Failed to copy: ' + err, 'error');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const objectUrl = URL.createObjectURL(value);
|
||||
setPreview(objectUrl);
|
||||
|
||||
// Clean up memory when the component is unmounted or the file changes
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setPreview(null);
|
||||
setImgWidth(0);
|
||||
setImgHeight(0);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) onChange(file);
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
// Handle image load to set dimensions
|
||||
const onImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const { naturalWidth: width, naturalHeight: height } = e.currentTarget;
|
||||
setImgWidth(width);
|
||||
setImgHeight(height);
|
||||
|
||||
// Initialize crop with a centered default crop if needed
|
||||
if (!crop.width && !crop.height && onCropChange) {
|
||||
const initialCrop: Crop = {
|
||||
unit: 'px',
|
||||
x: Math.floor(width / 4),
|
||||
y: Math.floor(height / 4),
|
||||
width: Math.floor(width / 2),
|
||||
height: Math.floor(height / 2)
|
||||
};
|
||||
|
||||
setCrop(initialCrop);
|
||||
|
||||
// Notify parent component of initial crop
|
||||
onCropChange(
|
||||
{ x: initialCrop.x, y: initialCrop.y },
|
||||
{ width: initialCrop.width, height: initialCrop.height }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle video load to set duration
|
||||
const onVideoLoad = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
||||
const duration = e.currentTarget.duration;
|
||||
setVideoDuration(duration);
|
||||
|
||||
// Initialize trim with full duration if needed
|
||||
if (onTrimChange && trimStart === 0 && trimEnd === 100) {
|
||||
onTrimChange(0, duration);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCropChange = (newCrop: Crop) => {
|
||||
setCrop(newCrop);
|
||||
};
|
||||
|
||||
const handleCropComplete = (crop: PixelCrop) => {
|
||||
if (onCropChange) {
|
||||
onCropChange(
|
||||
{ x: Math.round(crop.x * RATIO), y: Math.round(crop.y * RATIO) },
|
||||
{
|
||||
width: Math.round(crop.width * RATIO),
|
||||
height: Math.round(crop.height * RATIO)
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrimChange = (start: number, end: number) => {
|
||||
if (onTrimChange) {
|
||||
onTrimChange(start, end);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const clipboardItems = event.clipboardData?.items ?? [];
|
||||
const item = clipboardItems[0];
|
||||
if (
|
||||
item &&
|
||||
(item.type.includes('image') || item.type.includes('video'))
|
||||
) {
|
||||
const file = item.getAsFile();
|
||||
if (file) onChange(file);
|
||||
}
|
||||
};
|
||||
window.addEventListener('paste', handlePaste);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [onChange]);
|
||||
|
||||
// Format seconds to MM:SS format
|
||||
const formatTime = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<InputHeader title={title} />
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: globalInputHeight,
|
||||
border: preview ? 0 : 1,
|
||||
borderRadius: 2,
|
||||
boxShadow: '5',
|
||||
bgcolor: 'white',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{preview ? (
|
||||
<Box
|
||||
width="100%"
|
||||
height="100%"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: `url(${greyPattern})`,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{type === 'image' &&
|
||||
(showCropOverlay ? (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={handleCropChange}
|
||||
onComplete={handleCropComplete}
|
||||
circularCrop={cropShape === 'circular'}
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
>
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
</ReactCrop>
|
||||
) : (
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: '100%', maxHeight: globalInputHeight }}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
))}
|
||||
{type === 'video' && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={preview}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: showTrimControls ? 'calc(100% - 50px)' : '100%'
|
||||
}}
|
||||
onLoadedMetadata={onVideoLoad}
|
||||
controls={!showTrimControls}
|
||||
/>
|
||||
|
||||
{showTrimControls && videoDuration > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
padding: '10px 20px',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption">
|
||||
Start: {formatTime(trimStart || 0)}
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
End: {formatTime(trimEnd || videoDuration)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<div
|
||||
className="range-slider-container"
|
||||
style={{ margin: '20px 0', width: '100%' }}
|
||||
>
|
||||
<Slider
|
||||
range
|
||||
min={0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={[trimStart || 0, trimEnd || videoDuration]}
|
||||
onChange={(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
handleTrimChange(values[0], values[1]);
|
||||
}
|
||||
}}
|
||||
allowCross={false}
|
||||
pushable={0.1} // Minimum distance between handles
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{type === 'audio' && (
|
||||
<audio
|
||||
src={preview}
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '500px' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleImportClick}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 5,
|
||||
height: '100%',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<Typography color={theme.palette.grey['600']}>
|
||||
Click here to select a {type} from your device, press Ctrl+V to
|
||||
use a {type} from your clipboard, drag and drop a file from
|
||||
desktop
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<InputFooter handleCopy={handleCopy} handleImport={handleImportClick} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
accept={accept.join(',')}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
@@ -52,7 +52,7 @@ export default function ToolTextInput({
|
||||
rows={10}
|
||||
sx={{
|
||||
'&.MuiTextField-root': {
|
||||
backgroundColor: 'white'
|
||||
backgroundColor: 'background.paper'
|
||||
}
|
||||
}}
|
||||
inputProps={{
|
||||
|
@@ -30,7 +30,7 @@ const ColorSelector: React.FC<ColorSelectorProps & TextFieldProps> = ({
|
||||
<Box mb={1}>
|
||||
<Stack direction={'row'}>
|
||||
<TextField
|
||||
sx={{ backgroundColor: 'white' }}
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
value={color}
|
||||
onChange={handleColorChange}
|
||||
{...props}
|
||||
|
@@ -19,7 +19,7 @@ const TextFieldWithDesc = ({
|
||||
<Box>
|
||||
<TextField
|
||||
placeholder={placeholder}
|
||||
sx={{ backgroundColor: 'white' }}
|
||||
sx={{ backgroundColor: 'background.paper' }}
|
||||
value={value}
|
||||
onChange={(event) => onOwnChange(event.target.value)}
|
||||
{...props}
|
||||
|
@@ -36,7 +36,7 @@ export default function ToolOptions<T extends FormikValues>({
|
||||
mb: 2,
|
||||
borderRadius: 2,
|
||||
padding: 2,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
backgroundColor: 'background.lightSecondary',
|
||||
boxShadow: '2'
|
||||
}}
|
||||
mt={2}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { Box, CircularProgress, Typography, useTheme } from '@mui/material';
|
||||
import React, { useContext } from 'react';
|
||||
import InputHeader from '../InputHeader';
|
||||
import greyPattern from '@assets/grey-pattern.png';
|
||||
@@ -21,6 +21,7 @@ export default function ToolFileResult({
|
||||
}) {
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const { showSnackBar } = useContext(CustomSnackBarContext);
|
||||
const theme = useTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
@@ -87,7 +88,7 @@ export default function ToolFileResult({
|
||||
border: preview ? 0 : 1,
|
||||
borderRadius: 2,
|
||||
boxShadow: '5',
|
||||
bgcolor: 'white'
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -114,7 +115,8 @@ export default function ToolFileResult({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundImage: `url(${greyPattern})`
|
||||
backgroundImage:
|
||||
theme.palette.mode === 'dark' ? null : `url(${greyPattern})`
|
||||
}}
|
||||
>
|
||||
{fileType === 'image' && (
|
||||
|
@@ -52,7 +52,7 @@ export default function ToolTextResult({
|
||||
multiline
|
||||
sx={{
|
||||
'&.MuiTextField-root': {
|
||||
backgroundColor: 'white'
|
||||
backgroundColor: 'background.paper'
|
||||
}
|
||||
}}
|
||||
rows={10}
|
||||
|
@@ -1,13 +1,50 @@
|
||||
import { createTheme } from '@mui/material';
|
||||
import { createTheme, ThemeOptions } from '@mui/material';
|
||||
|
||||
const theme = createTheme({
|
||||
const sharedThemeOptions: ThemeOptions = {
|
||||
typography: {
|
||||
button: {
|
||||
textTransform: 'none'
|
||||
}
|
||||
},
|
||||
palette: { background: { default: '#ebf5ff' } },
|
||||
zIndex: { snackbar: 100000 }
|
||||
};
|
||||
export const lightTheme = createTheme({
|
||||
...sharedThemeOptions,
|
||||
palette: {
|
||||
background: {
|
||||
default: '#F5F5FA',
|
||||
hover: '#FAFAFD',
|
||||
lightSecondary: '#EBF5FF',
|
||||
darkSecondary: '#5581b5'
|
||||
}
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
contained: { color: '#ffffff', backgroundColor: '#1976d2' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default theme;
|
||||
export const darkTheme = createTheme({
|
||||
...sharedThemeOptions,
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
background: {
|
||||
default: '#1C1F20',
|
||||
paper: '#181a1b',
|
||||
hover: '#1a1c1d',
|
||||
lightSecondary: '#1E2021',
|
||||
darkSecondary: '#3C5F8A'
|
||||
},
|
||||
text: { primary: '#ffffff' }
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
contained: { color: '#ffffff', backgroundColor: '#145ea8' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { getToolsByCategory } from '@tools/index';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import { Box, Card, CardContent, Stack } from '@mui/material';
|
||||
import { Box, Card, CardContent, Stack, useTheme } from '@mui/material';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -19,6 +19,7 @@ const SingleCategory = function ({
|
||||
index: number;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const [hovered, setHovered] = useState<boolean>(false);
|
||||
const toggleHover = () => setHovered((prevState) => !prevState);
|
||||
return (
|
||||
@@ -32,7 +33,7 @@ const SingleCategory = function ({
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
backgroundColor: hovered ? '#FAFAFD' : 'white'
|
||||
backgroundColor: hovered ? 'background.hover' : 'background.paper'
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ height: '100%' }}>
|
||||
@@ -52,7 +53,11 @@ const SingleCategory = function ({
|
||||
color={categoriesColors[index % categoriesColors.length]}
|
||||
/>
|
||||
<Link
|
||||
style={{ fontSize: 20, fontWeight: 700, color: 'black' }}
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: theme.palette.mode === 'dark' ? 'white' : 'black'
|
||||
}}
|
||||
to={'/categories/' + category.type}
|
||||
>
|
||||
{category.title}
|
||||
@@ -70,7 +75,7 @@ const SingleCategory = function ({
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Button
|
||||
sx={{ backgroundColor: 'white' }}
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
fullWidth
|
||||
onClick={() => navigate(category.example.path)}
|
||||
variant={'outlined'}
|
||||
|
@@ -1,16 +1,23 @@
|
||||
import { Box } from '@mui/material';
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
import Hero from 'components/Hero';
|
||||
import Categories from './Categories';
|
||||
|
||||
export default function Home() {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Box
|
||||
padding={{
|
||||
xs: 1,
|
||||
md: 3,
|
||||
lg: 5,
|
||||
background: `url(/assets/background.svg)`,
|
||||
backgroundColor: '#F5F5FA'
|
||||
lg: 5
|
||||
}}
|
||||
sx={{
|
||||
background: `url(/assets/${
|
||||
theme.palette.mode === 'dark'
|
||||
? 'background-dark.png'
|
||||
: 'background.svg'
|
||||
})`,
|
||||
backgroundColor: 'background.default'
|
||||
}}
|
||||
display={'flex'}
|
||||
flexDirection={'column'}
|
||||
|
@@ -26,7 +26,7 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ backgroundColor: '#F5F5FA' }}>
|
||||
<Box sx={{ backgroundColor: 'background.default' }}>
|
||||
<Box
|
||||
padding={{ xs: 1, md: 3, lg: 5 }}
|
||||
display={'flex'}
|
||||
@@ -55,12 +55,14 @@ export default function Home() {
|
||||
<Grid item xs={12} md={6} lg={4} key={tool.path}>
|
||||
<Stack
|
||||
sx={{
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '5px 4px 2px #E9E9ED',
|
||||
backgroundColor: 'background.paper',
|
||||
boxShadow: `5px 4px 2px ${
|
||||
theme.palette.mode === 'dark' ? 'black' : '#E9E9ED'
|
||||
}`,
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.background.default // Change this to your desired hover color
|
||||
backgroundColor: theme.palette.background.hover
|
||||
}
|
||||
}}
|
||||
onClick={() => navigate('/' + tool.path)}
|
||||
@@ -77,7 +79,12 @@ export default function Home() {
|
||||
color={categoriesColors[index % categoriesColors.length]}
|
||||
/>
|
||||
<Box>
|
||||
<Link style={{ fontSize: 20 }} to={'/' + tool.path}>
|
||||
<Link
|
||||
style={{
|
||||
fontSize: 20
|
||||
}}
|
||||
to={'/' + tool.path}
|
||||
>
|
||||
{tool.name}
|
||||
</Link>
|
||||
<Typography sx={{ mt: 2 }}>
|
||||
|
@@ -22,7 +22,7 @@ const exampleCards: CardExampleType<InitialValuesType>[] = [
|
||||
{
|
||||
title: 'Convert Game Data from the CSV Format to the TSV Format',
|
||||
description:
|
||||
'This tool transforms Comma Separated Values (CSV) data to Tab Separated Values (TSV) data. Both CSV and TSV are popular file formats for storing tabular data but they use different delimiters to separate values – CSV uses commas (","), while TSV uses tabs ("\t"). If we compare CSV files to TSV files, then CSV files are much harder to parse than TSV files because the values themselves may contain commas, so it is not always obvious where one field starts and ends without complicated parsing rules. TSV, on the other hand, uses just a tab symbol, which does not usually appear in data, so separating fields in TSV is as simple as splitting the input by the tab character. To convert CSV to TSV, simply input the CSV data in the input of this tool. In rare cases when a CSV file has a delimiter other than a comma, you can specify the current delimiter in the options of the tool. You can also specify the current quote character and the comment start character. Additionally, empty CSV lines can be skipped by activating the "Ignore Lines with No Data" option. If this option is off, then empty lines in the CSV are converted to empty TSV lines. The "Preserve Headers" option allows you to choose whether to process column headers of a CSV file. If the option is selected, then the resulting TSV file will include the first row of the input CSV file, which contains the column names. Alternatively, if the headers option is not selected, the first row will be skipped during the data conversion process. For the reverse conversion from TSV to CSV, you can use our Convert TSV to CSV tool. Csv-abulous!',
|
||||
'In this example, we transform a Comma Separated Values (CSV) file containing a leaderboard of gaming data into a Tab Separated Values (TSV) file. The input data shows the players\' names, scores, times, and goals. We preserve the CSV column headers by enabling the "Preserve Headers" option and convert all data rows into TSV format. The resulting data is easier to work with as it\'s organized in neat columns',
|
||||
sampleText: `player_name,score,time,goals
|
||||
ToniJackson,2500,30:00,15
|
||||
HenryDalton,1800,25:00,12
|
||||
@@ -54,7 +54,7 @@ Vampire;Mythology;Castles;Immortality
|
||||
Phoenix;Mythology;Desert;Rebirth from ashes
|
||||
|
||||
#Dragon;Mythology;Mountains;Fire breathing
|
||||
#Werewolf;Mythology;Forests;Shapeshifting`,
|
||||
#Werewolf;Mythology;Forests;Shape shifting`,
|
||||
sampleResult: `Unicorn Mythology Forest Magic horn
|
||||
Mermaid Mythology Ocean Hypnotic singing
|
||||
Vampire Mythology Castles Immortality
|
||||
@@ -68,7 +68,7 @@ Phoenix Mythology Desert Rebirth from ashes`,
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Convet Fitness Tracker Data from CSV to TSV',
|
||||
title: 'Convert Fitness Tracker Data from CSV to TSV',
|
||||
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: `day,steps,distance,calories
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ToolFileInput from '@components/input/ToolFileInput';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
@@ -8,7 +7,6 @@ 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 = {
|
||||
|
183
src/pages/tools/video/compress/index.tsx
Normal file
183
src/pages/tools/video/compress/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
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 { compressVideo, VideoResolution } from './service';
|
||||
import SimpleRadio from '@components/options/SimpleRadio';
|
||||
import Slider from 'rc-slider';
|
||||
import 'rc-slider/assets/index.css';
|
||||
|
||||
export const initialValues = {
|
||||
width: 480 as VideoResolution,
|
||||
crf: 23,
|
||||
preset: 'medium'
|
||||
};
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
width: Yup.number()
|
||||
.oneOf(
|
||||
[240, 360, 480, 720, 1080],
|
||||
'Width must be one of the standard resolutions'
|
||||
)
|
||||
.required('Width is required'),
|
||||
crf: Yup.number()
|
||||
.min(0, 'CRF must be at least 0')
|
||||
.max(51, 'CRF must be at most 51')
|
||||
.required('CRF is required'),
|
||||
preset: Yup.string()
|
||||
.oneOf(
|
||||
[
|
||||
'ultrafast',
|
||||
'superfast',
|
||||
'veryfast',
|
||||
'faster',
|
||||
'fast',
|
||||
'medium',
|
||||
'slow',
|
||||
'slower',
|
||||
'veryslow'
|
||||
],
|
||||
'Preset must be a valid ffmpeg preset'
|
||||
)
|
||||
.required('Preset is required')
|
||||
});
|
||||
|
||||
const resolutionOptions: { value: VideoResolution; label: string }[] = [
|
||||
{ value: 240, label: '240p' },
|
||||
{ value: 360, label: '360p' },
|
||||
{ value: 480, label: '480p' },
|
||||
{ value: 720, label: '720p' },
|
||||
{ value: 1080, label: '1080p' }
|
||||
];
|
||||
|
||||
const presetOptions = [
|
||||
{ value: 'ultrafast', label: 'Ultrafast (Lowest Quality, Smallest Size)' },
|
||||
{ value: 'superfast', label: 'Superfast' },
|
||||
{ value: 'veryfast', label: 'Very Fast' },
|
||||
{ value: 'faster', label: 'Faster' },
|
||||
{ value: 'fast', label: 'Fast' },
|
||||
{ value: 'medium', label: 'Medium (Balanced)' },
|
||||
{ value: 'slow', label: 'Slow' },
|
||||
{ value: 'slower', label: 'Slower' },
|
||||
{ value: 'veryslow', label: 'Very Slow (Highest Quality, Largest Size)' }
|
||||
];
|
||||
|
||||
export default function CompressVideo({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const compute = async (
|
||||
optionsValues: typeof initialValues,
|
||||
input: File | null
|
||||
) => {
|
||||
if (!input) return;
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const compressedFile = await compressVideo(input, {
|
||||
width: optionsValues.width,
|
||||
crf: optionsValues.crf,
|
||||
preset: optionsValues.preset
|
||||
});
|
||||
setResult(compressedFile);
|
||||
} catch (error) {
|
||||
console.error('Error compressing video:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedCompute = useCallback(debounce(compute, 1000), []);
|
||||
|
||||
const getGroups: GetGroupsType<typeof initialValues> = ({
|
||||
values,
|
||||
updateField
|
||||
}) => [
|
||||
{
|
||||
title: 'Resolution',
|
||||
component: (
|
||||
<Box>
|
||||
{resolutionOptions.map((option) => (
|
||||
<SimpleRadio
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
checked={values.width === option.value}
|
||||
onClick={() => {
|
||||
updateField('width', option.value);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Quality (CRF)',
|
||||
component: (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Slider
|
||||
min={0}
|
||||
max={51}
|
||||
style={{ width: '90%' }}
|
||||
value={values.crf}
|
||||
onChange={(value) => {
|
||||
updateField('crf', typeof value === 'number' ? value : value[0]);
|
||||
}}
|
||||
marks={{
|
||||
0: 'Lossless',
|
||||
23: 'Default',
|
||||
51: 'Worst'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
// {
|
||||
// title: 'Encoding Preset',
|
||||
// component: (
|
||||
// <SelectWithDesc
|
||||
// selected={values.preset}
|
||||
// onChange={(value) => updateField('preset', value)}
|
||||
// options={presetOptions}
|
||||
// description={
|
||||
// 'Determines the compression speed. Slower presets provide better compression (quality per filesize) but take more time.'
|
||||
// }
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
];
|
||||
|
||||
return (
|
||||
<ToolContent
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolVideoInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['video/mp4', 'video/webm', 'video/ogg']}
|
||||
title={'Input Video'}
|
||||
/>
|
||||
}
|
||||
resultComponent={
|
||||
<ToolFileResult
|
||||
title={'Compressed Video'}
|
||||
value={result}
|
||||
extension={'mp4'}
|
||||
loading={loading}
|
||||
loadingText={'Compressing video...'}
|
||||
/>
|
||||
}
|
||||
initialValues={initialValues}
|
||||
getGroups={getGroups}
|
||||
compute={debouncedCompute}
|
||||
setInput={setInput}
|
||||
validationSchema={validationSchema}
|
||||
/>
|
||||
);
|
||||
}
|
20
src/pages/tools/video/compress/meta.ts
Normal file
20
src/pages/tools/video/compress/meta.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineTool } from '@tools/defineTool';
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const tool = defineTool('video', {
|
||||
name: 'Compress Video',
|
||||
path: 'compress',
|
||||
icon: 'icon-park-outline:compression',
|
||||
description:
|
||||
'Compress videos by scaling them to different resolutions like 240p, 480p, 720p, etc. This tool helps reduce file size while maintaining acceptable quality. Supports common video formats like MP4, WebM, and OGG.',
|
||||
shortDescription: 'Compress videos by scaling to different resolutions',
|
||||
keywords: [
|
||||
'compress',
|
||||
'video',
|
||||
'resize',
|
||||
'scale',
|
||||
'resolution',
|
||||
'reduce size'
|
||||
],
|
||||
component: lazy(() => import('./index'))
|
||||
});
|
60
src/pages/tools/video/compress/service.ts
Normal file
60
src/pages/tools/video/compress/service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
|
||||
const ffmpeg = new FFmpeg();
|
||||
|
||||
export type VideoResolution = 240 | 360 | 480 | 720 | 1080;
|
||||
|
||||
export interface CompressVideoOptions {
|
||||
width: VideoResolution;
|
||||
crf: number; // Constant Rate Factor (quality): lower = better quality, higher = smaller file
|
||||
preset: string; // Encoding speed preset
|
||||
}
|
||||
|
||||
export async function compressVideo(
|
||||
input: File,
|
||||
options: CompressVideoOptions
|
||||
): Promise<File> {
|
||||
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));
|
||||
|
||||
// Calculate height as -1 to maintain aspect ratio
|
||||
const scaleFilter = `scale=${options.width}:-2`;
|
||||
|
||||
const args = [
|
||||
'-i',
|
||||
inputName,
|
||||
'-vf',
|
||||
scaleFilter,
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-crf',
|
||||
options.crf.toString(),
|
||||
'-preset',
|
||||
options.preset,
|
||||
'-c:a',
|
||||
'aac', // Copy audio stream
|
||||
outputName
|
||||
];
|
||||
|
||||
try {
|
||||
await ffmpeg.exec(args);
|
||||
} catch (error) {
|
||||
console.error('FFmpeg execution failed:', error);
|
||||
}
|
||||
const compressedData = await ffmpeg.readFile(outputName);
|
||||
return new File(
|
||||
[new Blob([compressedData], { type: 'video/mp4' })],
|
||||
`${input.name.replace(/\.[^/.]+$/, '')}_compressed_${options.width}p.mp4`,
|
||||
{ type: 'video/mp4' }
|
||||
);
|
||||
}
|
@@ -1,21 +1,16 @@
|
||||
import { Box } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import ToolFileInput from '@components/input/ToolFileInput';
|
||||
import ToolFileResult from '@components/result/ToolFileResult';
|
||||
import TextFieldWithDesc from 'components/options/TextFieldWithDesc';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { FrameOptions, GifReader, GifWriter } from 'omggif';
|
||||
import { gifBinaryToFile } from '@utils/gif';
|
||||
import ToolContent from '@components/ToolContent';
|
||||
import { ToolComponentProps } from '@tools/defineTool';
|
||||
import ToolImageInput from '@components/input/ToolImageInput';
|
||||
import { FFmpeg } from '@ffmpeg/ffmpeg';
|
||||
import { fetchFile } from '@ffmpeg/util';
|
||||
|
||||
const initialValues = {
|
||||
newSpeed: 200
|
||||
newSpeed: 2
|
||||
};
|
||||
const validationSchema = Yup.object({
|
||||
// splitSeparator: Yup.string().required('The separator is required')
|
||||
});
|
||||
export default function ChangeSpeed({ title }: ToolComponentProps) {
|
||||
const [input, setInput] = useState<File | null>(null);
|
||||
const [result, setResult] = useState<File | null>(null);
|
||||
@@ -23,82 +18,64 @@ export default function ChangeSpeed({ title }: ToolComponentProps) {
|
||||
const compute = (optionsValues: typeof initialValues, input: File | null) => {
|
||||
if (!input) return;
|
||||
const { newSpeed } = optionsValues;
|
||||
// Initialize FFmpeg once in your component/app
|
||||
let ffmpeg: FFmpeg | null = null;
|
||||
let ffmpegLoaded = false;
|
||||
|
||||
const processImage = async (file: File, newSpeed: number) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(file);
|
||||
const processImage = async (
|
||||
file: File,
|
||||
newSpeed: number
|
||||
): Promise<void> => {
|
||||
if (!ffmpeg) {
|
||||
ffmpeg = new FFmpeg();
|
||||
}
|
||||
|
||||
reader.onload = async () => {
|
||||
const arrayBuffer = reader.result;
|
||||
if (!ffmpegLoaded) {
|
||||
await ffmpeg.load({
|
||||
wasmURL:
|
||||
'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.9/dist/esm/ffmpeg-core.wasm'
|
||||
});
|
||||
ffmpegLoaded = true;
|
||||
}
|
||||
|
||||
if (arrayBuffer instanceof ArrayBuffer) {
|
||||
const intArray = new Uint8Array(arrayBuffer);
|
||||
try {
|
||||
await ffmpeg.writeFile('input.gif', await fetchFile(file));
|
||||
|
||||
const reader = new GifReader(intArray as Buffer);
|
||||
const info = reader.frameInfo(0);
|
||||
const imageDataArr: ImageData[] = new Array(reader.numFrames())
|
||||
.fill(0)
|
||||
.map((_, k) => {
|
||||
const image = new ImageData(info.width, info.height);
|
||||
// Use FFmpeg's setpts filter to change the speed
|
||||
// PTS (Presentation Time Stamp) determines when each frame is shown
|
||||
// 1/speed changes the PTS - lower value = faster playback
|
||||
await ffmpeg.exec([
|
||||
'-i',
|
||||
'input.gif',
|
||||
'-filter:v',
|
||||
`setpts=${1 / newSpeed}*PTS`,
|
||||
'-f',
|
||||
'gif',
|
||||
'output.gif'
|
||||
]);
|
||||
|
||||
reader.decodeAndBlitFrameRGBA(k, image.data);
|
||||
// Read the result
|
||||
const data = await ffmpeg.readFile('output.gif');
|
||||
|
||||
return image;
|
||||
});
|
||||
const gif = new GifWriter(
|
||||
[],
|
||||
imageDataArr[0].width,
|
||||
imageDataArr[0].height,
|
||||
{ loop: 20 }
|
||||
);
|
||||
// Create a new file from the processed data
|
||||
const blob = new Blob([data], { type: 'image/gif' });
|
||||
const newFile = new File(
|
||||
[blob],
|
||||
file.name.replace('.gif', `-${newSpeed}x.gif`),
|
||||
{
|
||||
type: 'image/gif'
|
||||
}
|
||||
);
|
||||
|
||||
imageDataArr.forEach((imageData) => {
|
||||
const palette = [];
|
||||
const pixels = new Uint8Array(imageData.width * imageData.height);
|
||||
// Clean up to free memory
|
||||
await ffmpeg.deleteFile('input.gif');
|
||||
await ffmpeg.deleteFile('output.gif');
|
||||
|
||||
const { data } = imageData;
|
||||
for (let j = 0, k = 0, jl = data.length; j < jl; j += 4, k++) {
|
||||
const r = Math.floor(data[j] * 0.1) * 10;
|
||||
const g = Math.floor(data[j + 1] * 0.1) * 10;
|
||||
const b = Math.floor(data[j + 2] * 0.1) * 10;
|
||||
const color = (r << 16) | (g << 8) | (b << 0);
|
||||
|
||||
const index = palette.indexOf(color);
|
||||
|
||||
if (index === -1) {
|
||||
pixels[k] = palette.length;
|
||||
palette.push(color);
|
||||
} else {
|
||||
pixels[k] = index;
|
||||
}
|
||||
}
|
||||
|
||||
// Force palette to be power of 2
|
||||
|
||||
let powof2 = 1;
|
||||
while (powof2 < palette.length) powof2 <<= 1;
|
||||
palette.length = powof2;
|
||||
|
||||
const delay = newSpeed / 10; // Delay in hundredths of a sec (100 = 1s)
|
||||
const options: FrameOptions = {
|
||||
palette,
|
||||
delay
|
||||
};
|
||||
gif.addFrame(
|
||||
0,
|
||||
0,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
// @ts-ignore
|
||||
pixels,
|
||||
options
|
||||
);
|
||||
});
|
||||
const newFile = gifBinaryToFile(gif.getOutputBuffer(), file.name);
|
||||
|
||||
setResult(newFile);
|
||||
}
|
||||
};
|
||||
setResult(newFile);
|
||||
} catch (error) {
|
||||
console.error('Error processing GIF:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
processImage(input, newSpeed);
|
||||
@@ -108,7 +85,7 @@ export default function ChangeSpeed({ title }: ToolComponentProps) {
|
||||
title={title}
|
||||
input={input}
|
||||
inputComponent={
|
||||
<ToolFileInput
|
||||
<ToolImageInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
accept={['image/gif']}
|
||||
@@ -131,8 +108,7 @@ export default function ChangeSpeed({ title }: ToolComponentProps) {
|
||||
<TextFieldWithDesc
|
||||
value={values.newSpeed}
|
||||
onOwnChange={(val) => updateField('newSpeed', Number(val))}
|
||||
description={'Default new GIF speed.'}
|
||||
InputProps={{ endAdornment: <Typography>ms</Typography> }}
|
||||
description={'Default multiplier: 2 means 2x faster'}
|
||||
type={'number'}
|
||||
/>
|
||||
</Box>
|
||||
|
@@ -2,5 +2,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';
|
||||
import { tool as compressVideo } from './compress/meta';
|
||||
|
||||
export const videoTools = [...gifTools, trimVideo, rotateVideo];
|
||||
export const videoTools = [...gifTools, trimVideo, rotateVideo, compressVideo];
|
||||
|
@@ -94,7 +94,7 @@ export default function TrimVideo({ title }: ToolComponentProps) {
|
||||
}
|
||||
value={values.trimStart}
|
||||
label={'Start Time'}
|
||||
sx={{ mb: 2, backgroundColor: 'white' }}
|
||||
sx={{ mb: 2, backgroundColor: 'background.paper' }}
|
||||
/>
|
||||
<TextFieldWithDesc
|
||||
onOwnChange={(value) =>
|
||||
|
@@ -2,11 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./src",
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
@@ -19,30 +15,14 @@
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"vite/client",
|
||||
"vitest/globals",
|
||||
"@testing-library/jest-dom"
|
||||
],
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"],
|
||||
"paths": {
|
||||
"@tools/*": [
|
||||
"./tools/*"
|
||||
],
|
||||
"@assets/*": [
|
||||
"./assets/*"
|
||||
],
|
||||
"@components/*": [
|
||||
"./components/*"
|
||||
],
|
||||
"@utils/*": [
|
||||
"./utils/*"
|
||||
]
|
||||
"@tools/*": ["./tools/*"],
|
||||
"@assets/*": ["./assets/*"],
|
||||
"@components/*": ["./components/*"],
|
||||
"@utils/*": ["./utils/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"include": ["src", "./@types"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user