mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-06 13:48:58 +02:00
Merge 102b92190c
into 4e5f595951
This commit is contained in:
commit
96fe38be16
@ -347,6 +347,10 @@
|
||||
"title": "Rotate",
|
||||
"desc": "Easily rotate your PDFs."
|
||||
},
|
||||
"convert": {
|
||||
"title": "Convert",
|
||||
"desc": "Convert files between different formats",
|
||||
},
|
||||
"imageToPdf": {
|
||||
"title": "Image to PDF",
|
||||
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
|
||||
@ -643,6 +647,44 @@
|
||||
"selectAngle": "Select rotation angle (in multiples of 90 degrees):",
|
||||
"submit": "Rotate"
|
||||
},
|
||||
"convert":{
|
||||
"title": "Convert",
|
||||
"desc": "Convert files between different formats",
|
||||
"convertFrom": "Convert from",
|
||||
"convertTo": "Convert to",
|
||||
"outputOptions": "Output Options",
|
||||
"pdfOptions": "PDF Options",
|
||||
"imageOptions": "Image Options",
|
||||
"colorType": "Color Type",
|
||||
"color": "Color",
|
||||
"greyscale": "Greyscale",
|
||||
"blackwhite": "Black & White",
|
||||
"dpi": "DPI",
|
||||
"output": "Output",
|
||||
"single": "Single merged image",
|
||||
"multiple": "Multiple images (one per page)",
|
||||
"fileFormat": "File Format",
|
||||
"wordDoc": "Word Document",
|
||||
"wordDocExt": "Word Document (.docx)",
|
||||
"odtExt": "OpenDocument Text (.odt)",
|
||||
"pptExt": "PowerPoint (.pptx)",
|
||||
"odpExt": "OpenDocument Presentation (.odp)",
|
||||
"txtExt": "Plain Text (.txt)",
|
||||
"rtfExt": "Rich Text Format (.rtf)",
|
||||
"selectedFiles": "Selected files",
|
||||
"noFileSelected": "No file selected. Use the file panel to add files.",
|
||||
"convertFiles": "Convert Files",
|
||||
"converting": "Converting...",
|
||||
"downloadConverted": "Download Converted File",
|
||||
"errorNoFiles": "Please select at least one file to convert.",
|
||||
"errorNoFormat": "Please select both source and target formats.",
|
||||
"errorNotSupported": "Conversion from {{from}} to {{to}} is not supported.",
|
||||
"images": "Images",
|
||||
"officeDocs": "Office Documents (Word, Excel, PowerPoint)",
|
||||
"imagesExt": "Images (JPG, PNG, etc.)",
|
||||
"markdown": "Markdown",
|
||||
"textRtf": "Text/RTF"
|
||||
},
|
||||
"imageToPdf": {
|
||||
"tags": "conversion,img,jpg,picture,photo"
|
||||
},
|
||||
@ -1572,18 +1614,6 @@
|
||||
"pageEditor": "Page Editor",
|
||||
"fileManager": "File Manager"
|
||||
},
|
||||
"fileManager": {
|
||||
"dragDrop": "Drag & Drop files here",
|
||||
"clickToUpload": "Click to upload files",
|
||||
"selectedFiles": "Selected Files",
|
||||
"clearAll": "Clear All",
|
||||
"storage": "Storage",
|
||||
"filesStored": "files stored",
|
||||
"storageError": "Storage error occurred",
|
||||
"storageLow": "Storage is running low. Consider removing old files.",
|
||||
"uploadError": "Failed to upload some files.",
|
||||
"supportMessage": "Powered by browser database storage for unlimited capacity"
|
||||
},
|
||||
"pageEditor": {
|
||||
"title": "Page Editor",
|
||||
"save": "Save Changes",
|
||||
@ -1655,7 +1685,16 @@
|
||||
"failedToLoad": "Failed to load file to active set.",
|
||||
"storageCleared": "Browser cleared storage. Files have been removed. Please re-upload.",
|
||||
"clearAll": "Clear All",
|
||||
"reloadFiles": "Reload Files"
|
||||
"reloadFiles": "Reload Files",
|
||||
"dragDrop": "Drag & Drop files here",
|
||||
"clickToUpload": "Click to upload files",
|
||||
"selectedFiles": "Selected Files",
|
||||
"storage": "Storage",
|
||||
"filesStored": "files stored",
|
||||
"storageError": "Storage error occurred",
|
||||
"storageLow": "Storage is running low. Consider removing old files.",
|
||||
"supportMessage": "Powered by browser database storage for unlimited capacity",
|
||||
"noFileSelected": "No files selected"
|
||||
},
|
||||
"storage": {
|
||||
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
|
||||
|
@ -45,6 +45,13 @@ files,
|
||||
onPreviewFile={onPreviewFile}
|
||||
/>
|
||||
);
|
||||
case "convert":
|
||||
return (
|
||||
<ToolComponent
|
||||
selectedFiles={toolSelectedFiles}
|
||||
onPreviewFile={onPreviewFile}
|
||||
/>
|
||||
);
|
||||
case "merge":
|
||||
return (
|
||||
<ToolComponent
|
||||
|
228
frontend/src/components/tools/convert/ConvertSettings.tsx
Normal file
228
frontend/src/components/tools/convert/ConvertSettings.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Stack, Text, Select, NumberInput, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
|
||||
import GroupedFormatDropdown from "./GroupedFormatDropdown";
|
||||
import { ConvertParameters } from "../../../hooks/tools/convert/useConvertParameters";
|
||||
import {
|
||||
FROM_FORMAT_OPTIONS,
|
||||
COLOR_TYPES,
|
||||
OUTPUT_OPTIONS,
|
||||
EXTENSION_TO_ENDPOINT
|
||||
} from "../../../constants/convertConstants";
|
||||
|
||||
interface ConvertSettingsProps {
|
||||
parameters: ConvertParameters;
|
||||
onParameterChange: (key: keyof ConvertParameters, value: any) => void;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ConvertSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
getAvailableToExtensions,
|
||||
disabled = false
|
||||
}: ConvertSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
// Get all possible conversion endpoints to check their availability
|
||||
const allEndpoints = useMemo(() => {
|
||||
const endpoints = new Set<string>();
|
||||
Object.values(EXTENSION_TO_ENDPOINT).forEach(toEndpoints => {
|
||||
Object.values(toEndpoints).forEach(endpoint => {
|
||||
endpoints.add(endpoint);
|
||||
});
|
||||
});
|
||||
return Array.from(endpoints);
|
||||
}, []);
|
||||
|
||||
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
// Function to check if a conversion is available based on endpoint
|
||||
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (!endpointKey) return false;
|
||||
|
||||
return endpointStatus[endpointKey] === true;
|
||||
};
|
||||
|
||||
// Enhanced FROM options with endpoint availability
|
||||
const enhancedFromOptions = useMemo(() => {
|
||||
return FROM_FORMAT_OPTIONS.map(option => ({
|
||||
...option,
|
||||
enabled: true // All "from" formats are generally available for selection
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
|
||||
return availableOptions.map(option => ({
|
||||
...option,
|
||||
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
||||
}));
|
||||
}, [parameters.fromExtension, getAvailableToExtensions]);
|
||||
|
||||
const handleFromExtensionChange = (value: string) => {
|
||||
onParameterChange('fromExtension', value);
|
||||
// Reset to extension when from extension changes
|
||||
onParameterChange('toExtension', '');
|
||||
// Reset format-specific options
|
||||
onParameterChange('imageOptions', {
|
||||
colorType: COLOR_TYPES.COLOR,
|
||||
dpi: 300,
|
||||
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
|
||||
});
|
||||
};
|
||||
|
||||
const handleToExtensionChange = (value: string) => {
|
||||
onParameterChange('toExtension', value);
|
||||
// Reset format-specific options when target extension changes
|
||||
onParameterChange('imageOptions', {
|
||||
colorType: COLOR_TYPES.COLOR,
|
||||
dpi: 300,
|
||||
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Format Selection */}
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("convert.convertFrom", "Convert from")}:
|
||||
</Text>
|
||||
<GroupedFormatDropdown
|
||||
value={parameters.fromExtension}
|
||||
placeholder="Select source file format"
|
||||
options={enhancedFromOptions}
|
||||
onChange={handleFromExtensionChange}
|
||||
disabled={disabled}
|
||||
minWidth="21.875rem"
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("convert.convertTo", "Convert to")}:
|
||||
</Text>
|
||||
{!parameters.fromExtension ? (
|
||||
<UnstyledButton
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
border: `0.0625rem solid ${theme.colors.gray[4]}`,
|
||||
borderRadius: theme.radius.sm,
|
||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
|
||||
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6],
|
||||
cursor: 'not-allowed'
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">Select a source format first</Text>
|
||||
<KeyboardArrowDownIcon
|
||||
style={{
|
||||
fontSize: '1rem',
|
||||
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
) : (
|
||||
<GroupedFormatDropdown
|
||||
value={parameters.toExtension}
|
||||
placeholder="Select target file format"
|
||||
options={enhancedToOptions}
|
||||
onChange={handleToExtensionChange}
|
||||
disabled={disabled}
|
||||
minWidth="21.875rem"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Format-specific options */}
|
||||
{['png', 'jpg'].includes(parameters.toExtension) && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>{t("convert.imageOptions", "Image Options")}:</Text>
|
||||
<Group grow>
|
||||
<Select
|
||||
label={t("convert.colorType", "Color Type")}
|
||||
value={parameters.imageOptions.colorType}
|
||||
onChange={(val) => val && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
|
||||
})}
|
||||
data={[
|
||||
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
|
||||
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
|
||||
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("convert.dpi", "DPI")}
|
||||
value={parameters.imageOptions.dpi}
|
||||
onChange={(val) => typeof val === 'number' && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
dpi: val
|
||||
})}
|
||||
min={72}
|
||||
max={600}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
label={t("convert.output", "Output")}
|
||||
value={parameters.imageOptions.singleOrMultiple}
|
||||
onChange={(val) => val && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
singleOrMultiple: val as typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS]
|
||||
})}
|
||||
data={[
|
||||
{ value: OUTPUT_OPTIONS.SINGLE, label: t("convert.single", "Single") },
|
||||
{ value: OUTPUT_OPTIONS.MULTIPLE, label: t("convert.multiple", "Multiple") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
{/* Color options for image to PDF conversion */}
|
||||
{['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'].includes(parameters.fromExtension) && parameters.toExtension === 'pdf' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={500}>{t("convert.pdfOptions", "PDF Options")}:</Text>
|
||||
<Select
|
||||
label={t("convert.colorType", "Color Type")}
|
||||
value={parameters.imageOptions.colorType}
|
||||
onChange={(val) => val && onParameterChange('imageOptions', {
|
||||
...parameters.imageOptions,
|
||||
colorType: val as typeof COLOR_TYPES[keyof typeof COLOR_TYPES]
|
||||
})}
|
||||
data={[
|
||||
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
|
||||
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
|
||||
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertSettings;
|
147
frontend/src/components/tools/convert/GroupedFormatDropdown.tsx
Normal file
147
frontend/src/components/tools/convert/GroupedFormatDropdown.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
|
||||
interface FormatOption {
|
||||
value: string;
|
||||
label: string;
|
||||
group: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface GroupedFormatDropdownProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
options: FormatOption[];
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
minWidth?: string;
|
||||
}
|
||||
|
||||
const GroupedFormatDropdown = ({
|
||||
value,
|
||||
placeholder = "Select an option",
|
||||
options,
|
||||
onChange,
|
||||
disabled = false,
|
||||
minWidth = "18.75rem"
|
||||
}: GroupedFormatDropdownProps) => {
|
||||
const [dropdownOpened, setDropdownOpened] = useState(false);
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
// Group options by category
|
||||
const groupedOptions = useMemo(() => {
|
||||
const groups: Record<string, FormatOption[]> = {};
|
||||
|
||||
options.forEach(option => {
|
||||
if (!groups[option.group]) {
|
||||
groups[option.group] = [];
|
||||
}
|
||||
groups[option.group].push(option);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [options]);
|
||||
|
||||
// Get selected option label for display in format "Group (EXTENSION)"
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!value) return placeholder;
|
||||
const selected = options.find(opt => opt.value === value);
|
||||
return selected ? `${selected.group} (${selected.label})` : value.toUpperCase();
|
||||
}, [value, options, placeholder]);
|
||||
|
||||
const handleOptionSelect = (selectedValue: string) => {
|
||||
onChange(selectedValue);
|
||||
setDropdownOpened(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={dropdownOpened}
|
||||
onDismiss={() => setDropdownOpened(false)}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
shadow="sm"
|
||||
disabled={disabled}
|
||||
closeOnEscape={true}
|
||||
trapFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => setDropdownOpened(!dropdownOpened)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
padding: '0.5rem 0.75rem',
|
||||
border: `0.0625rem solid ${theme.colors.gray[4]}`,
|
||||
borderRadius: theme.radius.sm,
|
||||
backgroundColor: disabled
|
||||
? theme.colors.gray[1]
|
||||
: colorScheme === 'dark'
|
||||
? theme.colors.dark[6]
|
||||
: theme.white,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
width: '100%',
|
||||
color: colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.dark[9]
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={value ? undefined : 'dimmed'}>
|
||||
{selectedLabel}
|
||||
</Text>
|
||||
<KeyboardArrowDownIcon
|
||||
style={{
|
||||
fontSize: '1rem',
|
||||
transform: dropdownOpened ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
transition: 'transform 0.2s ease',
|
||||
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown
|
||||
style={{
|
||||
minWidth,
|
||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[7] : theme.white,
|
||||
border: `0.0625rem solid ${colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[4]}`,
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{Object.entries(groupedOptions).map(([groupName, groupOptions]) => (
|
||||
<Box key={groupName}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
c={colorScheme === 'dark' ? 'dark.2' : 'gray.6'}
|
||||
mb="xs"
|
||||
>
|
||||
{groupName}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{groupOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant={value === option.value ? "filled" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleOptionSelect(option.value)}
|
||||
disabled={option.enabled === false}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
height: '2rem',
|
||||
padding: '0 0.75rem'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupedFormatDropdown;
|
@ -33,14 +33,16 @@ const ToolStep = ({
|
||||
}: ToolStepProps) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
// Get context at the top level
|
||||
const parent = useContext(ToolStepContext);
|
||||
|
||||
// Auto-detect if we should show numbers based on sibling count
|
||||
const shouldShowNumber = useMemo(() => {
|
||||
if (showNumber !== undefined) return showNumber;
|
||||
const parent = useContext(ToolStepContext);
|
||||
return parent ? parent.visibleStepCount >= 3 : false;
|
||||
}, [showNumber]);
|
||||
}, [showNumber, parent]);
|
||||
|
||||
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
|
||||
const stepNumber = parent?.getStepNumber?.() || 1;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
|
159
frontend/src/constants/convertConstants.ts
Normal file
159
frontend/src/constants/convertConstants.ts
Normal file
@ -0,0 +1,159 @@
|
||||
export const FROM_FORMATS = {
|
||||
PDF: 'pdf',
|
||||
OFFICE: 'office',
|
||||
IMAGE: 'image',
|
||||
HTML: 'html',
|
||||
MARKDOWN: 'markdown',
|
||||
TEXT: 'text'
|
||||
} as const;
|
||||
|
||||
export const TO_FORMATS = {
|
||||
PDF: 'pdf',
|
||||
IMAGE: 'image',
|
||||
OFFICE_WORD: 'office-word',
|
||||
OFFICE_PRESENTATION: 'office-presentation',
|
||||
OFFICE_TEXT: 'office-text',
|
||||
HTML: 'html',
|
||||
XML: 'xml'
|
||||
} as const;
|
||||
|
||||
export const COLOR_TYPES = {
|
||||
COLOR: 'color',
|
||||
GREYSCALE: 'greyscale',
|
||||
BLACK_WHITE: 'blackwhite'
|
||||
} as const;
|
||||
|
||||
export const OUTPUT_OPTIONS = {
|
||||
SINGLE: 'single',
|
||||
MULTIPLE: 'multiple'
|
||||
} as const;
|
||||
|
||||
export const OFFICE_FORMATS = {
|
||||
DOCX: 'docx',
|
||||
ODT: 'odt',
|
||||
PPTX: 'pptx',
|
||||
ODP: 'odp',
|
||||
TXT: 'txt',
|
||||
RTF: 'rtf'
|
||||
} as const;
|
||||
|
||||
export const CONVERSION_ENDPOINTS = {
|
||||
'office-pdf': '/api/v1/convert/file/pdf',
|
||||
'pdf-image': '/api/v1/convert/pdf/img',
|
||||
'image-pdf': '/api/v1/convert/img/pdf',
|
||||
'pdf-office-word': '/api/v1/convert/pdf/word',
|
||||
'pdf-office-presentation': '/api/v1/convert/pdf/presentation',
|
||||
'pdf-office-text': '/api/v1/convert/pdf/text',
|
||||
'pdf-html': '/api/v1/convert/pdf/html',
|
||||
'pdf-xml': '/api/v1/convert/pdf/xml',
|
||||
'html-pdf': '/api/v1/convert/html/pdf',
|
||||
'markdown-pdf': '/api/v1/convert/markdown/pdf'
|
||||
} as const;
|
||||
|
||||
export const ENDPOINT_NAMES = {
|
||||
'office-pdf': 'file-to-pdf',
|
||||
'pdf-image': 'pdf-to-img',
|
||||
'image-pdf': 'img-to-pdf',
|
||||
'pdf-office-word': 'pdf-to-word',
|
||||
'pdf-office-presentation': 'pdf-to-presentation',
|
||||
'pdf-office-text': 'pdf-to-text',
|
||||
'pdf-html': 'pdf-to-html',
|
||||
'pdf-xml': 'pdf-to-xml',
|
||||
'html-pdf': 'html-to-pdf',
|
||||
'markdown-pdf': 'markdown-to-pdf'
|
||||
} as const;
|
||||
|
||||
export const SUPPORTED_CONVERSIONS: Record<string, string[]> = {
|
||||
[FROM_FORMATS.PDF]: [TO_FORMATS.IMAGE, TO_FORMATS.OFFICE_WORD, TO_FORMATS.OFFICE_PRESENTATION, TO_FORMATS.OFFICE_TEXT, TO_FORMATS.HTML, TO_FORMATS.XML],
|
||||
[FROM_FORMATS.OFFICE]: [TO_FORMATS.PDF],
|
||||
[FROM_FORMATS.IMAGE]: [TO_FORMATS.PDF],
|
||||
[FROM_FORMATS.HTML]: [TO_FORMATS.PDF],
|
||||
[FROM_FORMATS.MARKDOWN]: [TO_FORMATS.PDF],
|
||||
[FROM_FORMATS.TEXT]: [TO_FORMATS.PDF]
|
||||
};
|
||||
|
||||
export const FILE_EXTENSIONS = {
|
||||
[FROM_FORMATS.PDF]: ['pdf'],
|
||||
[FROM_FORMATS.OFFICE]: ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp'],
|
||||
[FROM_FORMATS.IMAGE]: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'],
|
||||
[FROM_FORMATS.HTML]: ['html', 'htm'],
|
||||
[FROM_FORMATS.MARKDOWN]: ['md'],
|
||||
[FROM_FORMATS.TEXT]: ['txt', 'rtf']
|
||||
};
|
||||
|
||||
// Grouped file extensions for dropdowns
|
||||
export const FROM_FORMAT_OPTIONS = [
|
||||
{ value: 'pdf', label: 'PDF', group: 'Document' },
|
||||
{ value: 'docx', label: 'DOCX', group: 'Document' },
|
||||
{ value: 'doc', label: 'DOC', group: 'Document' },
|
||||
{ value: 'odt', label: 'ODT', group: 'Document' },
|
||||
{ value: 'xlsx', label: 'XLSX', group: 'Spreadsheet' },
|
||||
{ value: 'xls', label: 'XLS', group: 'Spreadsheet' },
|
||||
{ value: 'ods', label: 'ODS', group: 'Spreadsheet' },
|
||||
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
|
||||
{ value: 'ppt', label: 'PPT', group: 'Presentation' },
|
||||
{ value: 'odp', label: 'ODP', group: 'Presentation' },
|
||||
{ value: 'jpg', label: 'JPG', group: 'Image' },
|
||||
{ value: 'jpeg', label: 'JPEG', group: 'Image' },
|
||||
{ value: 'png', label: 'PNG', group: 'Image' },
|
||||
{ value: 'gif', label: 'GIF', group: 'Image' },
|
||||
{ value: 'bmp', label: 'BMP', group: 'Image' },
|
||||
{ value: 'tiff', label: 'TIFF', group: 'Image' },
|
||||
{ value: 'webp', label: 'WEBP', group: 'Image' },
|
||||
{ value: 'html', label: 'HTML', group: 'Web' },
|
||||
{ value: 'htm', label: 'HTM', group: 'Web' },
|
||||
{ value: 'md', label: 'MD', group: 'Text' },
|
||||
{ value: 'txt', label: 'TXT', group: 'Text' },
|
||||
{ value: 'rtf', label: 'RTF', group: 'Text' },
|
||||
];
|
||||
|
||||
export const TO_FORMAT_OPTIONS = [
|
||||
{ value: 'pdf', label: 'PDF', group: 'Document' },
|
||||
{ value: 'docx', label: 'DOCX', group: 'Document' },
|
||||
{ value: 'odt', label: 'ODT', group: 'Document' },
|
||||
{ value: 'pptx', label: 'PPTX', group: 'Presentation' },
|
||||
{ value: 'odp', label: 'ODP', group: 'Presentation' },
|
||||
{ value: 'txt', label: 'TXT', group: 'Text' },
|
||||
{ value: 'rtf', label: 'RTF', group: 'Text' },
|
||||
{ value: 'png', label: 'PNG', group: 'Image' },
|
||||
{ value: 'jpg', label: 'JPG', group: 'Image' },
|
||||
{ value: 'html', label: 'HTML', group: 'Web' },
|
||||
{ value: 'xml', label: 'XML', group: 'Web' },
|
||||
];
|
||||
|
||||
// Conversion matrix - what each source format can convert to
|
||||
export const CONVERSION_MATRIX: Record<string, string[]> = {
|
||||
'pdf': ['png', 'jpg', 'docx', 'odt', 'pptx', 'odp', 'txt', 'rtf', 'html', 'xml'],
|
||||
'docx': ['pdf'], 'doc': ['pdf'], 'odt': ['pdf'],
|
||||
'xlsx': ['pdf'], 'xls': ['pdf'], 'ods': ['pdf'],
|
||||
'pptx': ['pdf'], 'ppt': ['pdf'], 'odp': ['pdf'],
|
||||
'jpg': ['pdf'], 'jpeg': ['pdf'], 'png': ['pdf'], 'gif': ['pdf'], 'bmp': ['pdf'], 'tiff': ['pdf'], 'webp': ['pdf'],
|
||||
'html': ['pdf'], 'htm': ['pdf'],
|
||||
'md': ['pdf'],
|
||||
'txt': ['pdf'], 'rtf': ['pdf']
|
||||
};
|
||||
|
||||
// Map extensions to endpoint keys
|
||||
export const EXTENSION_TO_ENDPOINT: Record<string, Record<string, string>> = {
|
||||
'pdf': {
|
||||
'png': 'pdf-to-img', 'jpg': 'pdf-to-img',
|
||||
'docx': 'pdf-to-word', 'odt': 'pdf-to-word',
|
||||
'pptx': 'pdf-to-presentation', 'odp': 'pdf-to-presentation',
|
||||
'txt': 'pdf-to-text', 'rtf': 'pdf-to-text',
|
||||
'html': 'pdf-to-html', 'xml': 'pdf-to-xml'
|
||||
},
|
||||
'docx': { 'pdf': 'file-to-pdf' }, 'doc': { 'pdf': 'file-to-pdf' }, 'odt': { 'pdf': 'file-to-pdf' },
|
||||
'xlsx': { 'pdf': 'file-to-pdf' }, 'xls': { 'pdf': 'file-to-pdf' }, 'ods': { 'pdf': 'file-to-pdf' },
|
||||
'pptx': { 'pdf': 'file-to-pdf' }, 'ppt': { 'pdf': 'file-to-pdf' }, 'odp': { 'pdf': 'file-to-pdf' },
|
||||
'jpg': { 'pdf': 'img-to-pdf' }, 'jpeg': { 'pdf': 'img-to-pdf' }, 'png': { 'pdf': 'img-to-pdf' },
|
||||
'gif': { 'pdf': 'img-to-pdf' }, 'bmp': { 'pdf': 'img-to-pdf' }, 'tiff': { 'pdf': 'img-to-pdf' }, 'webp': { 'pdf': 'img-to-pdf' },
|
||||
'html': { 'pdf': 'html-to-pdf' }, 'htm': { 'pdf': 'html-to-pdf' },
|
||||
'md': { 'pdf': 'markdown-to-pdf' },
|
||||
'txt': { 'pdf': 'file-to-pdf' }, 'rtf': { 'pdf': 'file-to-pdf' }
|
||||
};
|
||||
|
||||
export type FromFormat = typeof FROM_FORMATS[keyof typeof FROM_FORMATS];
|
||||
export type ToFormat = typeof TO_FORMATS[keyof typeof TO_FORMATS];
|
||||
export type ColorType = typeof COLOR_TYPES[keyof typeof COLOR_TYPES];
|
||||
export type OutputOption = typeof OUTPUT_OPTIONS[keyof typeof OUTPUT_OPTIONS];
|
||||
export type OfficeFormat = typeof OFFICE_FORMATS[keyof typeof OFFICE_FORMATS];
|
245
frontend/src/hooks/tools/convert/useConvertOperation.ts
Normal file
245
frontend/src/hooks/tools/convert/useConvertOperation.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { FileOperation } from '../../../types/fileContext';
|
||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
||||
import { makeApiUrl } from '../../../utils/api';
|
||||
import { ConvertParameters } from './useConvertParameters';
|
||||
import {
|
||||
CONVERSION_ENDPOINTS,
|
||||
ENDPOINT_NAMES,
|
||||
EXTENSION_TO_ENDPOINT
|
||||
} from '../../../constants/convertConstants';
|
||||
|
||||
export interface ConvertOperationHook {
|
||||
executeOperation: (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
) => Promise<void>;
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files: File[];
|
||||
thumbnails: string[];
|
||||
isGeneratingThumbnails: boolean;
|
||||
downloadUrl: string | null;
|
||||
downloadFilename: string;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
|
||||
// Result management functions
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useConvertOperation = (): ConvertOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
markOperationFailed,
|
||||
addFiles
|
||||
} = useFileContext();
|
||||
|
||||
// Internal state management
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadFilename, setDownloadFilename] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const buildFormData = useCallback((
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
|
||||
const { fromExtension, toExtension, imageOptions } = parameters;
|
||||
|
||||
// Add conversion-specific parameters
|
||||
if (['png', 'jpg'].includes(toExtension)) {
|
||||
formData.append("imageFormat", toExtension === 'jpg' ? 'jpg' : 'png');
|
||||
formData.append("colorType", imageOptions.colorType);
|
||||
formData.append("dpi", imageOptions.dpi.toString());
|
||||
formData.append("singleOrMultiple", imageOptions.singleOrMultiple);
|
||||
} else if (fromExtension === 'pdf' && ['docx', 'odt'].includes(toExtension)) {
|
||||
formData.append("outputFormat", toExtension);
|
||||
} else if (fromExtension === 'pdf' && ['pptx', 'odp'].includes(toExtension)) {
|
||||
formData.append("outputFormat", toExtension);
|
||||
} else if (fromExtension === 'pdf' && ['txt', 'rtf'].includes(toExtension)) {
|
||||
formData.append("outputFormat", toExtension);
|
||||
} else if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'].includes(fromExtension) && toExtension === 'pdf') {
|
||||
formData.append("fitOption", "fillPage");
|
||||
formData.append("colorType", imageOptions.colorType);
|
||||
formData.append("autoRotate", "true");
|
||||
}
|
||||
|
||||
return formData;
|
||||
}, []);
|
||||
|
||||
const createOperation = useCallback((
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||
const operationId = `convert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const fileId = selectedFiles[0].name;
|
||||
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'convert',
|
||||
timestamp: Date.now(),
|
||||
fileIds: selectedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: selectedFiles[0].name,
|
||||
parameters: {
|
||||
fromExtension: parameters.fromExtension,
|
||||
toExtension: parameters.toExtension,
|
||||
imageOptions: parameters.imageOptions,
|
||||
},
|
||||
fileSize: selectedFiles[0].size
|
||||
}
|
||||
};
|
||||
|
||||
return { operation, operationId, fileId };
|
||||
}, []);
|
||||
|
||||
const processResults = useCallback(async (blob: Blob, filename: string) => {
|
||||
try {
|
||||
// For single file conversions, create a file directly
|
||||
const convertedFile = new File([blob], filename, { type: blob.type });
|
||||
|
||||
// Set local state for preview
|
||||
setFiles([convertedFile]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
// Add converted file to FileContext for future use
|
||||
await addFiles([convertedFile]);
|
||||
|
||||
// Generate thumbnail for preview
|
||||
try {
|
||||
const thumbnail = await generateThumbnailForFile(convertedFile);
|
||||
setThumbnails([thumbnail]);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${filename}:`, error);
|
||||
setThumbnails(['']);
|
||||
}
|
||||
|
||||
setIsGeneratingThumbnails(false);
|
||||
} catch (error) {
|
||||
console.warn('Failed to process conversion result:', error);
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
if (selectedFiles.length === 0) {
|
||||
setStatus(t("noFileSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { operation, operationId, fileId } = createOperation(parameters, selectedFiles);
|
||||
const formData = buildFormData(parameters, selectedFiles);
|
||||
|
||||
// Get endpoint using constants
|
||||
const getEndpoint = () => {
|
||||
const { fromExtension, toExtension } = parameters;
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension];
|
||||
if (!endpointKey) return '';
|
||||
|
||||
// Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name
|
||||
for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) {
|
||||
if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointKey) {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const endpoint = getEndpoint();
|
||||
if (!endpoint) {
|
||||
setErrorMessage(t("convert.errorNotSupported", { from: parameters.fromExtension, to: parameters.toExtension }));
|
||||
return;
|
||||
}
|
||||
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
setStatus(t("loading"));
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Generate filename based on conversion
|
||||
const originalName = selectedFiles[0].name.split('.')[0];
|
||||
const filename = `${originalName}_converted.${parameters.toExtension}`;
|
||||
|
||||
setDownloadUrl(url);
|
||||
setDownloadFilename(filename);
|
||||
setStatus(t("downloadComplete"));
|
||||
|
||||
await processResults(blob, filename);
|
||||
markOperationApplied(fileId, operationId);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
let errorMsg = t("convert.errorConversion", "An error occurred while converting the file.");
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMsg = error.response.data;
|
||||
} else if (error.message) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
setErrorMessage(errorMsg);
|
||||
setStatus(t("error._value", "Conversion failed."));
|
||||
markOperationFailed(fileId, operationId, errorMsg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(false);
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
executeOperation,
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files,
|
||||
thumbnails,
|
||||
isGeneratingThumbnails,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
status,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
|
||||
// Result management functions
|
||||
resetResults,
|
||||
clearError,
|
||||
};
|
||||
};
|
129
frontend/src/hooks/tools/convert/useConvertParameters.ts
Normal file
129
frontend/src/hooks/tools/convert/useConvertParameters.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
FROM_FORMATS,
|
||||
TO_FORMATS,
|
||||
COLOR_TYPES,
|
||||
OUTPUT_OPTIONS,
|
||||
OFFICE_FORMATS,
|
||||
CONVERSION_ENDPOINTS,
|
||||
ENDPOINT_NAMES,
|
||||
SUPPORTED_CONVERSIONS,
|
||||
FILE_EXTENSIONS,
|
||||
FROM_FORMAT_OPTIONS,
|
||||
TO_FORMAT_OPTIONS,
|
||||
CONVERSION_MATRIX,
|
||||
EXTENSION_TO_ENDPOINT,
|
||||
type FromFormat,
|
||||
type ToFormat,
|
||||
type ColorType,
|
||||
type OutputOption,
|
||||
type OfficeFormat
|
||||
} from '../../../constants/convertConstants';
|
||||
|
||||
export interface ConvertParameters {
|
||||
fromExtension: string;
|
||||
toExtension: string;
|
||||
imageOptions: {
|
||||
colorType: ColorType;
|
||||
dpi: number;
|
||||
singleOrMultiple: OutputOption;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConvertParametersHook {
|
||||
parameters: ConvertParameters;
|
||||
updateParameter: (parameter: keyof ConvertParameters, value: any) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
getEndpoint: () => string;
|
||||
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
|
||||
detectFileExtension: (filename: string) => string;
|
||||
}
|
||||
|
||||
const initialParameters: ConvertParameters = {
|
||||
fromExtension: '',
|
||||
toExtension: '',
|
||||
imageOptions: {
|
||||
colorType: COLOR_TYPES.COLOR,
|
||||
dpi: 300,
|
||||
singleOrMultiple: OUTPUT_OPTIONS.MULTIPLE,
|
||||
},
|
||||
};
|
||||
|
||||
export const useConvertParameters = (): ConvertParametersHook => {
|
||||
const [parameters, setParameters] = useState<ConvertParameters>(initialParameters);
|
||||
|
||||
const updateParameter = (parameter: keyof ConvertParameters, value: any) => {
|
||||
setParameters(prev => ({ ...prev, [parameter]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(initialParameters);
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
const { fromExtension, toExtension } = parameters;
|
||||
|
||||
if (!fromExtension || !toExtension) return false;
|
||||
|
||||
// Check if conversion is supported
|
||||
const supportedToExtensions = CONVERSION_MATRIX[fromExtension];
|
||||
if (!supportedToExtensions || !supportedToExtensions.includes(toExtension)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional validation for image conversions
|
||||
if (['png', 'jpg'].includes(toExtension)) {
|
||||
return parameters.imageOptions.dpi >= 72 && parameters.imageOptions.dpi <= 600;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
const { fromExtension, toExtension } = parameters;
|
||||
if (!fromExtension || !toExtension) return '';
|
||||
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExtension]?.[toExtension];
|
||||
return endpointKey || '';
|
||||
};
|
||||
|
||||
const getEndpoint = () => {
|
||||
const endpointName = getEndpointName();
|
||||
if (!endpointName) return '';
|
||||
|
||||
// Find the endpoint URL from CONVERSION_ENDPOINTS using the endpoint name
|
||||
for (const [key, endpoint] of Object.entries(CONVERSION_ENDPOINTS)) {
|
||||
if (ENDPOINT_NAMES[key as keyof typeof ENDPOINT_NAMES] === endpointName) {
|
||||
return endpoint;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const getAvailableToExtensions = (fromExtension: string) => {
|
||||
if (!fromExtension) return [];
|
||||
|
||||
const supportedExtensions = CONVERSION_MATRIX[fromExtension] || [];
|
||||
return TO_FORMAT_OPTIONS.filter(option =>
|
||||
supportedExtensions.includes(option.value)
|
||||
);
|
||||
};
|
||||
|
||||
const detectFileExtension = (filename: string): string => {
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
return extension || '';
|
||||
};
|
||||
|
||||
return {
|
||||
parameters,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
getEndpoint,
|
||||
getAvailableToExtensions,
|
||||
detectFileExtension,
|
||||
};
|
||||
};
|
@ -3,9 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
import CompressPdfPanel from "../tools/Compress";
|
||||
import MergePdfPanel from "../tools/Merge";
|
||||
import ConvertPanel from "../tools/Convert";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
|
||||
type ToolRegistryEntry = {
|
||||
@ -23,6 +25,7 @@ const baseToolRegistry = {
|
||||
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "split" },
|
||||
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "compress" },
|
||||
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "pageEditor" },
|
||||
convert: { icon: <SwapHorizIcon />, component: ConvertPanel, view: "convert" },
|
||||
};
|
||||
|
||||
// Tool endpoint mappings
|
||||
@ -30,6 +33,7 @@ const toolEndpoints: Record<string, string[]> = {
|
||||
split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"],
|
||||
compress: ["compress-pdf"],
|
||||
merge: ["merge-pdfs"],
|
||||
convert: ["pdf-to-img", "img-to-pdf", "pdf-to-word", "pdf-to-presentation", "pdf-to-text", "pdf-to-html", "pdf-to-xml", "html-to-pdf", "markdown-to-pdf", "file-to-pdf"],
|
||||
};
|
||||
|
||||
|
||||
|
@ -224,6 +224,11 @@ export default function HomePage() {
|
||||
setCurrentView('compress');
|
||||
setLeftPanelView('toolContent');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else if (previousMode === 'convert') {
|
||||
selectTool('convert');
|
||||
setCurrentView('convert');
|
||||
setLeftPanelView('toolContent');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else {
|
||||
setCurrentView('fileEditor');
|
||||
}
|
||||
@ -273,6 +278,16 @@ export default function HomePage() {
|
||||
setToolSelectedFiles(files);
|
||||
}}
|
||||
/>
|
||||
) : currentView === "convert" ? (
|
||||
<FileEditor
|
||||
toolMode={true}
|
||||
multiSelect={false}
|
||||
showUpload={true}
|
||||
showBulkActions={true}
|
||||
onFileSelect={(files) => {
|
||||
setToolSelectedFiles(files);
|
||||
}}
|
||||
/>
|
||||
) : selectedToolKey && selectedTool ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
|
172
frontend/src/tools/Convert.tsx
Normal file
172
frontend/src/tools/Convert.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||
import ResultsPreview from "../components/tools/shared/ResultsPreview";
|
||||
|
||||
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||
|
||||
import { useConvertParameters } from "../hooks/tools/convert/useConvertParameters";
|
||||
import { useConvertOperation } from "../hooks/tools/convert/useConvertOperation";
|
||||
|
||||
interface ConvertProps {
|
||||
selectedFiles?: File[];
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
const Convert = ({ selectedFiles = [], onPreviewFile }: ConvertProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setCurrentMode } = useFileContext();
|
||||
|
||||
const convertParams = useConvertParameters();
|
||||
const convertOperation = useConvertOperation();
|
||||
|
||||
// Endpoint validation
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
||||
convertParams.getEndpointName()
|
||||
);
|
||||
|
||||
// Auto-detect extension when files change
|
||||
useEffect(() => {
|
||||
if (selectedFiles.length > 0 && !convertParams.parameters.fromExtension) {
|
||||
const firstFile = selectedFiles[0];
|
||||
const detectedExtension = convertParams.detectFileExtension(firstFile.name);
|
||||
if (detectedExtension) {
|
||||
convertParams.updateParameter('fromExtension', detectedExtension);
|
||||
}
|
||||
}
|
||||
}, [selectedFiles, convertParams.parameters.fromExtension]);
|
||||
|
||||
useEffect(() => {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [convertParams.parameters, selectedFiles]);
|
||||
|
||||
const handleConvert = async () => {
|
||||
await convertOperation.executeOperation(
|
||||
convertParams.parameters,
|
||||
selectedFiles
|
||||
);
|
||||
};
|
||||
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem('previousMode', 'convert');
|
||||
setCurrentMode('viewer');
|
||||
};
|
||||
|
||||
const handleSettingsReset = () => {
|
||||
convertOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
setCurrentMode('convert');
|
||||
};
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = convertOperation.downloadUrl !== null;
|
||||
const filesCollapsed = hasFiles;
|
||||
const settingsCollapsed = hasResults;
|
||||
|
||||
const previewResults = useMemo(() =>
|
||||
convertOperation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: convertOperation.thumbnails[index]
|
||||
})) || [],
|
||||
[convertOperation.files, convertOperation.thumbnails]
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolStepContainer>
|
||||
<Stack gap="sm" h="100%" p="sm" style={{ overflow: 'auto' }}>
|
||||
{/* Files Step */}
|
||||
<ToolStep
|
||||
title="Files"
|
||||
isVisible={true}
|
||||
isCollapsed={filesCollapsed}
|
||||
isCompleted={filesCollapsed}
|
||||
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
|
||||
>
|
||||
<FileStatusIndicator
|
||||
selectedFiles={selectedFiles}
|
||||
placeholder="Select a file in the main view to get started"
|
||||
/>
|
||||
</ToolStep>
|
||||
|
||||
{/* Settings Step */}
|
||||
<ToolStep
|
||||
title="Settings"
|
||||
isVisible={hasFiles}
|
||||
isCollapsed={settingsCollapsed}
|
||||
isCompleted={settingsCollapsed}
|
||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
||||
completedMessage={settingsCollapsed ? "Conversion completed" : undefined}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<ConvertSettings
|
||||
parameters={convertParams.parameters}
|
||||
onParameterChange={convertParams.updateParameter}
|
||||
getAvailableToExtensions={convertParams.getAvailableToExtensions}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
|
||||
{convertParams.parameters.fromExtension && convertParams.parameters.toExtension && (
|
||||
<OperationButton
|
||||
onClick={handleConvert}
|
||||
isLoading={convertOperation.isLoading}
|
||||
disabled={!convertParams.validateParameters() || !hasFiles || !endpointEnabled}
|
||||
loadingText={t("convert.converting", "Converting...")}
|
||||
submitText={t("convert.convertFiles", "Convert Files")}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
|
||||
{/* Results Step */}
|
||||
<ToolStep
|
||||
title="Results"
|
||||
isVisible={hasResults}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
{convertOperation.status && (
|
||||
<Text size="sm" c="dimmed">{convertOperation.status}</Text>
|
||||
)}
|
||||
|
||||
<ErrorNotification
|
||||
error={convertOperation.errorMessage}
|
||||
onClose={convertOperation.clearError}
|
||||
/>
|
||||
|
||||
{convertOperation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={convertOperation.downloadUrl}
|
||||
download={convertOperation.downloadFilename || "converted_file"}
|
||||
leftSection={<DownloadIcon />}
|
||||
color="green"
|
||||
fullWidth
|
||||
mb="md"
|
||||
>
|
||||
{t("convert.downloadConverted", "Download Converted File")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<ResultsPreview
|
||||
files={previewResults}
|
||||
onFileClick={handleThumbnailClick}
|
||||
isGeneratingThumbnails={convertOperation.isGeneratingThumbnails}
|
||||
title="Conversion Results"
|
||||
/>
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
</Stack>
|
||||
</ToolStepContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Convert;
|
Loading…
Reference in New Issue
Block a user