Merge branch 'main' into chesterkxng

This commit is contained in:
Chesterkxng
2025-05-24 02:57:17 +02:00
57 changed files with 6574 additions and 2493 deletions

View File

@@ -40,7 +40,7 @@ const FormikListenerComponent = <T,>({
interface ToolContentProps<T, I> extends ToolComponentProps {
inputComponent?: ReactNode;
resultComponent: ReactNode;
resultComponent?: ReactNode;
renderCustomInput?: (
values: T,
setFieldValue: (fieldName: string, value: any) => void
@@ -57,6 +57,7 @@ interface ToolContentProps<T, I> extends ToolComponentProps {
setInput?: React.Dispatch<React.SetStateAction<I>>;
validationSchema?: any;
onValuesChange?: (values: T) => void;
verticalGroups?: boolean;
}
export default function ToolContent<T extends FormikValues, I>({
@@ -72,7 +73,8 @@ export default function ToolContent<T extends FormikValues, I>({
setInput,
validationSchema,
renderCustomInput,
onValuesChange
onValuesChange,
verticalGroups
}: ToolContentProps<T, I>) {
return (
<Box>
@@ -97,7 +99,7 @@ export default function ToolContent<T extends FormikValues, I>({
input={input}
onValuesChange={onValuesChange}
/>
<ToolOptions getGroups={getGroups} />
<ToolOptions getGroups={getGroups} vertical={verticalGroups} />
{toolInfo && toolInfo.title && toolInfo.description && (
<ToolInfo

View File

@@ -6,18 +6,20 @@ export default function ToolInputAndResult({
result
}: {
input?: ReactNode;
result: ReactNode;
result?: ReactNode;
}) {
return (
<Grid id="tool" container spacing={2}>
{input && (
<Grid item xs={12} md={6}>
{input}
if (input || result) {
return (
<Grid id="tool" container spacing={2}>
{input && (
<Grid item xs={12} md={6}>
{input}
</Grid>
)}
<Grid item xs={12} md={input ? 6 : 12}>
{result}
</Grid>
)}
<Grid item xs={12} md={input ? 6 : 12}>
{result}
</Grid>
</Grid>
);
);
}
}

View File

@@ -1,4 +1,4 @@
import React, { ReactNode, useContext, useEffect } from 'react';
import React, { ReactNode, useContext, useEffect, useState } from 'react';
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
@@ -11,6 +11,7 @@ import {
import { globalInputHeight } from '../../config/uiConfig';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import greyPattern from '@assets/grey-pattern.png';
import { isArray } from 'lodash';
interface BaseFileInputComponentProps extends BaseFileInputProps {
children: (props: { preview: string | undefined }) => ReactNode;
@@ -25,20 +26,22 @@ export default function BaseFileInput({
children,
type
}: BaseFileInputComponentProps) {
const [preview, setPreview] = React.useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
const theme = useTheme();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
useEffect(() => {
if (value) {
const objectUrl = createObjectURL(value);
setPreview(objectUrl);
return () => revokeObjectURL(objectUrl);
} else {
setPreview(null);
}
try {
const objectUrl = createObjectURL(value);
setPreview(objectUrl);
return () => revokeObjectURL(objectUrl);
} catch (error) {
console.error('Error previewing file:', error);
}
} else setPreview(null);
}, [value]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -49,10 +52,11 @@ export default function BaseFileInput({
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleCopy = () => {
if (value) {
const blob = new Blob([value], { type: value.type });
const clipboardItem = new ClipboardItem({ [value.type]: blob });
if (isArray(value)) {
const blob = new Blob([value[0]], { type: value[0].type });
const clipboardItem = new ClipboardItem({ [value[0].type]: blob });
navigator.clipboard
.write([clipboardItem])
@@ -63,6 +67,52 @@ export default function BaseFileInput({
}
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
const file = event.dataTransfer.files[0];
// Check if file type is acceptable
const isAcceptable = accept.some((acceptType) => {
// Handle wildcards like "image/*"
if (acceptType.endsWith('/*')) {
const category = acceptType.split('/')[0];
return file.type.startsWith(category);
}
return acceptType === file.type;
});
if (isAcceptable) {
onChange(file);
} else {
showSnackBar(
`Invalid file type. Please use ${accept.join(', ')}`,
'error'
);
}
}
};
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
};
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
};
useEffect(() => {
const handlePaste = (event: ClipboardEvent) => {
const clipboardItems = event.clipboardData?.items ?? [];
@@ -95,8 +145,15 @@ export default function BaseFileInput({
borderRadius: 2,
boxShadow: '5',
bgcolor: 'background.paper',
position: 'relative'
position: 'relative',
borderColor: isDragging ? theme.palette.primary.main : undefined,
borderWidth: isDragging ? 2 : 1,
borderStyle: isDragging ? 'dashed' : 'solid'
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
>
{preview ? (
<Box
@@ -127,17 +184,27 @@ export default function BaseFileInput({
cursor: 'pointer'
}}
>
<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
</Typography>
{isDragging ? (
<Typography
color={theme.palette.primary.main}
variant="h6"
align="center"
>
Drop your {type} here
</Typography>
) : (
<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, or drag and drop a file from
desktop
</Typography>
)}
</Box>
)}
</Box>
@@ -148,6 +215,7 @@ export default function BaseFileInput({
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={false}
/>
</Box>
);

View File

@@ -2,23 +2,32 @@ import { Stack } from '@mui/material';
import Button from '@mui/material/Button';
import PublishIcon from '@mui/icons-material/Publish';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import React from 'react';
import ClearIcon from '@mui/icons-material/Clear';
export default function InputFooter({
handleImport,
handleCopy
handleCopy,
handleClear
}: {
handleImport: () => void;
handleCopy: () => void;
handleCopy?: () => void;
handleClear?: () => void;
}) {
return (
<Stack mt={1} direction={'row'} spacing={2}>
<Button onClick={handleImport} startIcon={<PublishIcon />}>
Import from file
</Button>
<Button onClick={handleCopy} startIcon={<ContentPasteIcon />}>
Copy to clipboard
</Button>
{handleCopy && (
<Button onClick={handleCopy} startIcon={<ContentPasteIcon />}>
Copy to clipboard
</Button>
)}
{handleClear && (
<Button onClick={handleClear} startIcon={<ClearIcon />}>
Clear
</Button>
)}
</Stack>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { Grid, Select, MenuItem } from '@mui/material';
import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
import Qty from 'js-quantities';
//
const siPrefixes: { [key: string]: number } = {
'Default prefix': 1,
k: 1000,
M: 1000000,
G: 1000000000,
T: 1000000000000,
m: 0.001,
u: 0.000001,
n: 0.000000001,
p: 0.000000000001
};
export default function NumericInputWithUnit(props: {
value: { value: number; unit: string };
disabled?: boolean;
disableChangingUnit?: boolean;
onOwnChange?: (value: { value: number; unit: string }) => void;
defaultPrefix?: string;
}) {
const [inputValue, setInputValue] = useState(props.value.value);
const [prefix, setPrefix] = useState(props.defaultPrefix || 'Default prefix');
// internal display unit
const [unit, setUnit] = useState('');
// Whether user has overridden the unit
const [userSelectedUnit, setUserSelectedUnit] = useState(false);
const [unitKind, setUnitKind] = useState('');
const [unitOptions, setUnitOptions] = useState<string[]>([]);
const [disabled, setDisabled] = useState(props.disabled);
const [disableChangingUnit, setDisableChangingUnit] = useState(
props.disableChangingUnit
);
useEffect(() => {
setDisabled(props.disabled);
setDisableChangingUnit(props.disableChangingUnit);
}, [props.disabled, props.disableChangingUnit]);
useEffect(() => {
if (unitKind != Qty(props.value.unit).kind()) {
// Update the options for what units similar to this one are available
const kind = Qty(props.value.unit).kind();
let units: string[] = [];
if (kind) {
units = Qty.getUnits(kind);
}
if (!units.includes(props.value.unit)) {
units.push(props.value.unit);
}
// Workaround because the lib doesn't list them
if (kind == 'area') {
units.push('km^2');
units.push('mile^2');
units.push('inch^2');
units.push('m^2');
units.push('cm^2');
}
setUnitOptions(units);
setInputValue(props.value.value);
setUnit(props.value.unit);
setUnitKind(kind);
setUserSelectedUnit(false);
return;
}
if (userSelectedUnit) {
if (!isNaN(props.value.value)) {
const converted = Qty(props.value.value, props.value.unit).to(
unit
).scalar;
setInputValue(converted);
} else {
setInputValue(props.value.value);
}
} else {
setInputValue(props.value.value);
setUnit(props.value.unit);
}
}, [props.value.value, props.value.unit, unit]);
const handleUserValueChange = (newValue: number) => {
setInputValue(newValue);
if (props.onOwnChange) {
try {
const converted = Qty(newValue * siPrefixes[prefix], unit).to(
props.value.unit
).scalar;
props.onOwnChange({ unit: props.value.unit, value: converted });
} catch (error) {
console.error('Conversion error', error);
}
}
};
const handlePrefixChange = (newPrefix: string) => {
setPrefix(newPrefix);
};
const handleUserUnitChange = (newUnit: string) => {
if (!newUnit) return;
const oldInputValue = inputValue;
const oldUnit = unit;
setUnit(newUnit);
setPrefix('Default prefix');
const convertedValue = Qty(oldInputValue * siPrefixes[prefix], oldUnit).to(
newUnit
).scalar;
setInputValue(convertedValue);
};
return (
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<TextFieldWithDesc
disabled={disabled}
type="number"
fullWidth
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
value={(inputValue / siPrefixes[prefix])
.toFixed(9)
.replace(/(\d*\.\d+?)0+$/, '$1')}
onOwnChange={(value) => handleUserValueChange(parseFloat(value))}
/>
</Grid>
<Grid item xs={12} md={3}>
<Select
fullWidth
disabled={disableChangingUnit}
value={prefix}
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
onChange={(evt) => {
handlePrefixChange(evt.target.value || '');
}}
>
{Object.keys(siPrefixes).map((key) => (
<MenuItem key={key} value={key}>
{key}
</MenuItem>
))}
</Select>
</Grid>
<Grid item xs={12} md={5}>
<Select
fullWidth
disabled={disableChangingUnit}
placeholder={'Unit'}
sx={{ width: { xs: '75%', sm: '80%', md: '90%' } }}
value={unit}
onChange={(event) => {
setUserSelectedUnit(true);
handleUserUnitChange(event.target.value || '');
}}
>
{unitOptions.map((key) => (
<MenuItem key={key} value={key}>
{key}
</MenuItem>
))}
</Select>
</Grid>
</Grid>
);
}

View File

@@ -0,0 +1,176 @@
import { ReactNode, useContext, useEffect, useRef, useState } from 'react';
import { Box, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography';
import InputHeader from '../InputHeader';
import InputFooter from './InputFooter';
import { CustomSnackBarContext } from '../../contexts/CustomSnackBarContext';
import { isArray } from 'lodash';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
interface MultiPdfInputComponentProps {
accept: string[];
title?: string;
type: 'pdf';
value: MultiPdfInput[];
onChange: (file: MultiPdfInput[]) => void;
}
export interface MultiPdfInput {
file: File;
order: number;
}
export default function ToolMultiFileInput({
value,
onChange,
accept,
title,
type
}: MultiPdfInputComponentProps) {
const theme = useTheme();
const fileInputRef = useRef<HTMLInputElement>(null);
const { showSnackBar } = useContext(CustomSnackBarContext);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (files)
onChange([
...value,
...Array.from(files).map((file) => ({ file, order: value.length }))
]);
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
function handleClear() {
onChange([]);
}
function fileNameTruncate(fileName: string) {
const maxLength = 10;
if (fileName.length > maxLength) {
return fileName.slice(0, maxLength) + '...';
}
return fileName;
}
const sortList = () => {
const list = [...value];
list.sort((a, b) => a.order - b.order);
onChange(list);
};
const reorderList = (sourceIndex: number, destinationIndex: number) => {
console.log(sourceIndex, destinationIndex);
if (destinationIndex === sourceIndex) {
return;
}
const list = [...value];
if (destinationIndex === 0) {
list[sourceIndex].order = list[0].order - 1;
sortList();
return;
}
if (destinationIndex === list.length - 1) {
list[sourceIndex].order = list[list.length - 1].order + 1;
sortList();
return;
}
if (destinationIndex < sourceIndex) {
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex - 1].order) / 2;
sortList();
return;
}
list[sourceIndex].order =
(list[destinationIndex].order + list[destinationIndex + 1].order) / 2;
sortList();
};
return (
<Box>
<InputHeader
title={title || 'Input ' + type.charAt(0).toUpperCase() + type.slice(1)}
/>
<Box
sx={{
width: '100%',
height: '300px',
border: value?.length ? 0 : 1,
borderRadius: 2,
boxShadow: '5',
bgcolor: 'background.paper',
position: 'relative'
}}
>
<Box
width="100%"
height="100%"
sx={{
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
position: 'relative'
}}
>
{value?.length ? (
value.map((file, index) => (
<Box
key={index}
sx={{
margin: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '200px',
border: 1,
borderRadius: 1,
padding: 1
}}
>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<PictureAsPdfIcon />
<Typography sx={{ marginLeft: 1 }}>
{fileNameTruncate(file.file.name)}
</Typography>
</Box>
<Box
sx={{ cursor: 'pointer' }}
onClick={() => {
const updatedFiles = value.filter((_, i) => i !== index);
onChange(updatedFiles);
}}
>
</Box>
</Box>
))
) : (
<Typography variant="body2" color="text.secondary">
No files selected
</Typography>
)}
</Box>
</Box>
<InputFooter handleImport={handleImportClick} handleClear={handleClear} />
<input
ref={fileInputRef}
style={{ display: 'none' }}
type="file"
accept={accept.join(',')}
onChange={handleFileChange}
multiple={true}
/>
</Box>
);
}

View File

@@ -5,11 +5,12 @@ import 'rc-slider/assets/index.css';
import BaseFileInput from './BaseFileInput';
import { BaseFileInputProps, formatTime } from './file-input-utils';
interface VideoFileInputProps extends BaseFileInputProps {
interface VideoFileInputProps extends Omit<BaseFileInputProps, 'accept'> {
showTrimControls?: boolean;
onTrimChange?: (trimStart: number, trimEnd: number) => void;
trimStart?: number;
trimEnd?: number;
accept?: string[];
}
export default function ToolVideoInput({
@@ -17,6 +18,7 @@ export default function ToolVideoInput({
onTrimChange,
trimStart = 0,
trimEnd = 100,
accept = ['video/*', '.mkv'],
...props
}: VideoFileInputProps) {
const videoRef = useRef<HTMLVideoElement>(null);
@@ -38,7 +40,7 @@ export default function ToolVideoInput({
};
return (
<BaseFileInput {...props} type={'video'}>
<BaseFileInput {...props} type={'video'} accept={accept}>
{({ preview }) => (
<Box
sx={{

View File

@@ -13,10 +13,12 @@ export type GetGroupsType<T> = (
export default function ToolOptions<T extends FormikValues>({
children,
getGroups
getGroups,
vertical
}: {
children?: ReactNode;
getGroups: GetGroupsType<T> | null;
vertical?: boolean;
}) {
const theme = useTheme();
const formikContext = useFormikContext<T>();
@@ -49,6 +51,7 @@ export default function ToolOptions<T extends FormikValues>({
<Stack direction={'row'} spacing={2}>
<ToolOptionGroups
groups={getGroups({ ...formikContext, updateField }) ?? []}
vertical={vertical}
/>
{children}
</Stack>

View File

@@ -173,7 +173,9 @@ export default function ToolFileResult({
disabled={!value}
handleCopy={handleCopy}
handleDownload={handleDownload}
hideCopy={fileType === 'video' || fileType === 'audio'}
hideCopy={
fileType === 'video' || fileType === 'audio' || fileType === 'pdf'
}
/>
</Box>
);