improvement/v2/automate/tweaks (#4293)

- [x] Cleanup Automation output name garbage			
- [x] Remove Cross button on first two tools			
- [x] Automation creation name title to make clearer to the user
- [x] Colours for dark mode on automation tool settings are bad 	
- [x] Fix tool names not using correct translated ones 
- [x] suggested Automation Password needs adding to description 
- [x] Allow different filetypes in automation
- [x] Custom Icons for automation
- [x] split Tool wasn't working with merge to single pdf

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
ConnorYoh
2025-08-26 16:59:03 +01:00
committed by GitHub
parent 3d26b054f1
commit 47ccb6a6ed
25 changed files with 582 additions and 134 deletions

View File

@@ -32,9 +32,10 @@ const FileUploadButton = ({
onChange={onChange}
accept={accept}
disabled={disabled}
>
{(props) => (
<Button {...props} variant={variant} fullWidth={fullWidth}>
<Button {...props} variant={variant} fullWidth={fullWidth} color="blue">
{file ? file.name : (placeholder || defaultPlaceholder)}
</Button>
)}

View File

@@ -22,7 +22,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode } = useToolWorkflow();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@@ -74,7 +74,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
type: 'navigation',
onClick: () => {
setActiveButton('automate');
handleToolSelect('automate');
// If already on automate tool, reset it directly
if (selectedToolKey === 'automate') {
resetTool('automate');
} else {
handleToolSelect('automate');
}
}
},
{

View File

@@ -16,7 +16,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={watermarkType === 'text' ? 'filled' : 'outline'}
color={watermarkType === 'text' ? 'blue' : 'gray'}
color={watermarkType === 'text' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('text')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
@@ -27,7 +27,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled
</Button>
<Button
variant={watermarkType === 'image' ? 'filled' : 'outline'}
color={watermarkType === 'image' ? 'blue' : 'gray'}
color={watermarkType === 'image' ? 'blue' : 'var(--text-muted)'}
onClick={() => onWatermarkTypeChange('image')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}

View File

@@ -6,6 +6,7 @@ import {
Stack,
Group,
TextInput,
Textarea,
Divider,
Modal
} from '@mantine/core';
@@ -13,6 +14,7 @@ import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal';
import ToolList from './ToolList';
import IconSelector from './IconSelector';
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
@@ -31,6 +33,10 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
const {
automationName,
setAutomationName,
automationDescription,
setAutomationDescription,
automationIcon,
setAutomationIcon,
selectedTools,
addTool,
removeTool,
@@ -100,7 +106,8 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
const automationData = {
name: automationName.trim(),
description: '',
description: automationDescription.trim(),
icon: automationIcon,
operations: selectedTools.map(tool => ({
operation: tool.operation,
parameters: tool.parameters || {}
@@ -114,7 +121,7 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
if (mode === AutomationMode.EDIT && existingAutomation) {
// For edit mode, check if name has changed
const nameChanged = automationName.trim() !== existingAutomation.name;
if (nameChanged) {
// Name changed - create new automation
savedAutomation = await automationStorage.saveAutomation(automationData);
@@ -144,17 +151,39 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
return (
<div>
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
{t("automate.creation.intro", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
</Text>
<Divider mb="md" />
<Stack gap="md">
{/* Automation Name */}
<TextInput
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
value={automationName}
onChange={(e) => setAutomationName(e.currentTarget.value)}
{/* Automation Name and Icon */}
<Group gap="xs" align="flex-end">
<Stack gap="xs" style={{ flex: 1 }}>
<TextInput
placeholder={t('automate.creation.name.placeholder', 'My Automation')}
value={automationName}
withAsterisk
label={t('automate.creation.name.label', 'Automation Name')}
onChange={(e) => setAutomationName(e.currentTarget.value)}
size="sm"
/>
</Stack>
<IconSelector
value={automationIcon || 'SettingsIcon'}
onChange={setAutomationIcon}
size="sm"
/>
</Group>
{/* Automation Description */}
<Textarea
placeholder={t('automate.creation.description.placeholder', 'Describe what this automation does...')}
value={automationDescription}
label={t('automate.creation.description.label', 'Description')}
onChange={(e) => setAutomationDescription(e.currentTarget.value)}
size="sm"
rows={3}
/>

View File

@@ -6,6 +6,7 @@ import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
@@ -28,6 +29,8 @@ interface AutomationEntryProps {
onDelete?: () => void;
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<string, ToolRegistryEntry>;
}
export default function AutomationEntry({
@@ -40,7 +43,8 @@ export default function AutomationEntry({
showMenu = false,
onEdit,
onDelete,
onCopy
onCopy,
toolRegistry
}: AutomationEntryProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -49,9 +53,19 @@ export default function AutomationEntry({
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation]?.name) {
return toolRegistry[operation].name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);
};
// Create tooltip content with description and tool chain
const createTooltipContent = () => {
if (!description) return null;
// Show tooltip if there's a description OR if there are operations to show in the chain
if (!description && operations.length === 0) return null;
const toolChain = operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
@@ -68,7 +82,7 @@ export default function AutomationEntry({
whiteSpace: 'nowrap'
}}
>
{t(`${op}.title`, op)}
{getToolDisplayName(op)}
</Text>
{index < operations.length - 1 && (
<Text component="span" size="sm" mx={4}>
@@ -80,12 +94,16 @@ export default function AutomationEntry({
return (
<div style={{ minWidth: '400px', width: 'auto' }}>
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
{description}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
{toolChain}
</div>
{description && (
<Text size="sm" mb={8} style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}>
{description}
</Text>
)}
{operations.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', whiteSpace: 'nowrap' }}>
{toolChain}
</div>
)}
</div>
);
};
@@ -122,7 +140,7 @@ export default function AutomationEntry({
{operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t(`${op}.title`, op)}
{getToolDisplayName(op)}
</Text>
{index < operations.length - 1 && (
@@ -221,8 +239,10 @@ export default function AutomationEntry({
</Box>
);
// Only show tooltip if description exists, otherwise return plain content
return description ? (
// Show tooltip if there's a description OR operations to display
const shouldShowTooltip = description || operations.length > 0;
return shouldShowTooltip ? (
<Tooltip
content={createTooltipContent()}
position="right"

View File

@@ -6,6 +6,8 @@ import SettingsIcon from "@mui/icons-material/Settings";
import AutomationEntry from "./AutomationEntry";
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
import { AutomationConfig, SuggestedAutomation } from "../../../types/automation";
import { iconMap } from './iconMap';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
@@ -14,6 +16,7 @@ interface AutomationSelectionProps {
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
onCopyFromSuggested: (automation: SuggestedAutomation) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
}
export default function AutomationSelection({
@@ -22,7 +25,8 @@ export default function AutomationSelection({
onRun,
onEdit,
onDelete,
onCopyFromSuggested
onCopyFromSuggested,
toolRegistry
}: AutomationSelectionProps) {
const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations();
@@ -40,20 +44,26 @@ export default function AutomationSelection({
operations={[]}
onClick={onCreateNew}
keepIconColor={true}
toolRegistry={toolRegistry}
/>
{/* Saved Automations */}
{savedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
title={automation.name}
badgeIcon={SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
/>
))}
{savedAutomations.map((automation) => {
const IconComponent = automation.icon ? iconMap[automation.icon as keyof typeof iconMap] : SettingsIcon;
return (
<AutomationEntry
key={automation.id}
title={automation.name}
description={automation.description}
badgeIcon={IconComponent || SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
toolRegistry={toolRegistry}
/>
);
})}
<Divider pb='sm' />
{/* Suggested Automations */}
@@ -72,6 +82,7 @@ export default function AutomationSelection({
onClick={() => onRun(automation)}
showMenu={true}
onCopy={() => onCopyFromSuggested(automation)}
toolRegistry={toolRegistry}
/>
))}
</Stack>

View File

@@ -0,0 +1,116 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Text, Stack, Button, SimpleGrid, Tooltip, Popover } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { iconMap, iconOptions } from './iconMap';
interface IconSelectorProps {
value?: string;
onChange?: (iconKey: string) => void;
size?: "sm" | "md" | "lg";
}
export default function IconSelector({ value = "SettingsIcon", onChange, size = "sm" }: IconSelectorProps) {
const { t } = useTranslation();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const selectedIconComponent = iconMap[value as keyof typeof iconMap] || iconMap.SettingsIcon;
const handleIconSelect = (iconKey: string) => {
onChange?.(iconKey);
setIsDropdownOpen(false);
};
const iconSize = size === "sm" ? 16 : size === "md" ? 18 : 20;
return (
<Stack gap="1px">
<Text size="sm" fw={600} style={{ color: "var(--mantine-color-primary)" }}>
{t("automate.creation.icon.label", "Icon")}
</Text>
<Popover
opened={isDropdownOpen}
onClose={() => setIsDropdownOpen(false)}
onDismiss={() => setIsDropdownOpen(false)}
position="bottom-start"
withArrow
trapFocus
>
<Popover.Target>
<Button
variant="outline"
size={size}
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
style={{
width: size === "sm" ? "3.75rem" : "4.375rem",
position: "relative",
display: "flex",
justifyContent: "flex-start",
paddingLeft: "0.5rem",
borderColor: "var(--mantine-color-gray-3)",
color: "var(--mantine-color-text)",
}}
>
{React.createElement(selectedIconComponent, { style: { fontSize: iconSize } })}
<KeyboardArrowDownIcon
style={{
fontSize: iconSize * 0.8,
position: "absolute",
right: "0.25rem",
top: "50%",
transform: "translateY(-50%)",
}}
/>
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<SimpleGrid cols={4} spacing="xs">
{iconOptions.map((option) => {
const IconComponent = iconMap[option.value as keyof typeof iconMap];
const isSelected = value === option.value;
return (
<Tooltip key={option.value} label={option.label}>
<Box
onClick={() => handleIconSelect(option.value)}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0.5rem",
borderRadius: "0.25rem",
cursor: "pointer",
backgroundColor: isSelected ? "var(--mantine-color-gray-1)" : "transparent",
}}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = "var(--mantine-color-gray-0)";
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
>
<IconComponent
style={{
fontSize: iconSize,
color: isSelected ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-7)",
}}
/>
</Box>
</Tooltip>
);
})}
</SimpleGrid>
</Stack>
</Popover.Dropdown>
</Popover>
</Stack>
);
}

View File

@@ -63,22 +63,24 @@ export default function ToolList({
borderBottomWidth: tool.operation && !tool.configured ? "0" : "1px",
}}
>
{/* Delete X in top right */}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => onToolRemove(index)}
title={t("automate.creation.tools.remove", "Remove tool")}
style={{
position: "absolute",
top: "4px",
right: "4px",
zIndex: 1,
color: "var(--mantine-color-gray-6)",
}}
>
<CloseIcon style={{ fontSize: 16 }} />
</ActionIcon>
{/* Delete X in top right - only show for tools after the first 2 */}
{index > 1 && (
<ActionIcon
variant="subtle"
size="xs"
onClick={() => onToolRemove(index)}
title={t("automate.creation.tools.remove", "Remove tool")}
style={{
position: "absolute",
top: "4px",
right: "4px",
zIndex: 1,
color: "var(--mantine-color-gray-6)",
}}
>
<CloseIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
<div style={{ paddingRight: "1.25rem" }}>
{/* Tool Selection Dropdown with inline settings cog */}

View File

@@ -0,0 +1,92 @@
import SettingsIcon from '@mui/icons-material/Settings';
import CompressIcon from '@mui/icons-material/Compress';
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
import CleaningServicesIcon from '@mui/icons-material/CleaningServices';
import CropIcon from '@mui/icons-material/Crop';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import FolderIcon from '@mui/icons-material/Folder';
import CloudIcon from '@mui/icons-material/Cloud';
import StorageIcon from '@mui/icons-material/Storage';
import SearchIcon from '@mui/icons-material/Search';
import DownloadIcon from '@mui/icons-material/Download';
import UploadIcon from '@mui/icons-material/Upload';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import VisibilityIcon from '@mui/icons-material/Visibility';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import WorkIcon from '@mui/icons-material/Work';
import BuildIcon from '@mui/icons-material/Build';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import CheckIcon from '@mui/icons-material/Check';
import SecurityIcon from '@mui/icons-material/Security';
import StarIcon from '@mui/icons-material/Star';
export const iconMap = {
SettingsIcon,
CompressIcon,
SwapHorizIcon,
CleaningServicesIcon,
CropIcon,
TextFieldsIcon,
PictureAsPdfIcon,
EditIcon,
DeleteIcon,
FolderIcon,
CloudIcon,
StorageIcon,
SearchIcon,
DownloadIcon,
UploadIcon,
PlayArrowIcon,
RotateLeftIcon,
RotateRightIcon,
VisibilityIcon,
ContentCutIcon,
ContentCopyIcon,
WorkIcon,
BuildIcon,
AutoAwesomeIcon,
SmartToyIcon,
CheckIcon,
SecurityIcon,
StarIcon
};
export const iconOptions = [
{ value: 'SettingsIcon', label: 'Settings' },
{ value: 'CompressIcon', label: 'Compress' },
{ value: 'SwapHorizIcon', label: 'Convert' },
{ value: 'CleaningServicesIcon', label: 'Clean' },
{ value: 'CropIcon', label: 'Crop' },
{ value: 'TextFieldsIcon', label: 'Text' },
{ value: 'PictureAsPdfIcon', label: 'PDF' },
{ value: 'EditIcon', label: 'Edit' },
{ value: 'DeleteIcon', label: 'Delete' },
{ value: 'FolderIcon', label: 'Folder' },
{ value: 'CloudIcon', label: 'Cloud' },
{ value: 'StorageIcon', label: 'Storage' },
{ value: 'SearchIcon', label: 'Search' },
{ value: 'DownloadIcon', label: 'Download' },
{ value: 'UploadIcon', label: 'Upload' },
{ value: 'PlayArrowIcon', label: 'Play' },
{ value: 'RotateLeftIcon', label: 'Rotate Left' },
{ value: 'RotateRightIcon', label: 'Rotate Right' },
{ value: 'VisibilityIcon', label: 'View' },
{ value: 'ContentCutIcon', label: 'Cut' },
{ value: 'ContentCopyIcon', label: 'Copy' },
{ value: 'WorkIcon', label: 'Work' },
{ value: 'BuildIcon', label: 'Build' },
{ value: 'AutoAwesomeIcon', label: 'Magic' },
{ value: 'SmartToyIcon', label: 'Robot' },
{ value: 'CheckIcon', label: 'Check' },
{ value: 'SecurityIcon', label: 'Security' },
{ value: 'StarIcon', label: 'Star' }
];
export type IconKey = keyof typeof iconMap;

View File

@@ -23,7 +23,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={parameters.compressionMethod === 'quality' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'gray'}
color={parameters.compressionMethod === 'quality' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'quality')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
@@ -34,7 +34,7 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
</Button>
<Button
variant={parameters.compressionMethod === 'filesize' ? 'filled' : 'outline'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'gray'}
color={parameters.compressionMethod === 'filesize' ? 'blue' : 'var(--text-muted)'}
onClick={() => onParameterChange('compressionMethod', 'filesize')}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}