diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index eba9a12c5..c328a350d 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -4,9 +4,12 @@ import { useTranslation } from 'react-i18next'; import CloseIcon from '@mui/icons-material/Close'; import VisibilityIcon from '@mui/icons-material/Visibility'; import HistoryIcon from '@mui/icons-material/History'; +import PushPinIcon from '@mui/icons-material/PushPin'; +import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import styles from './PageEditor.module.css'; import FileOperationHistory from '../history/FileOperationHistory'; +import { useFileContext } from '../../contexts/FileContext'; interface FileItem { id: string; @@ -66,6 +69,10 @@ const FileThumbnail = ({ }: FileThumbnailProps) => { const { t } = useTranslation(); const [showHistory, setShowHistory] = useState(false); + const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + + // Find the actual File object that corresponds to this FileItem + const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size); const formatFileSize = (bytes: number) => { if (bytes === 0) return '0 B'; @@ -301,6 +308,32 @@ const FileThumbnail = ({ + {actualFile && ( + + { + e.stopPropagation(); + if (isFilePinned(actualFile)) { + unpinFile(actualFile); + onSetStatus(`Unpinned ${file.name}`); + } else { + pinFile(actualFile); + onSetStatus(`Pinned ${file.name}`); + } + }} + > + {isFilePinned(actualFile) ? ( + + ) : ( + + )} + + + )} + + {/* Pin indicator - always visible when pinned */} + {isPinned && ( +
+ +
+ )} + {/* Hover action buttons */} - {isHovered && (onView || onEdit) && ( + {isHovered && (onView || onEdit || true) && (
e.stopPropagation()} > + {/* Pin/Unpin button */} + + { + e.stopPropagation(); + if (isPinned) { + unpinFile(file as File); + } else { + pinFile(file as File); + } + }} + > + {isPinned ? : } + + + {onView && ( { + const { pinFile, unpinFile, isFilePinned } = useFileContext(); if (selectedFiles.length === 0) { return ( diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index 21f3649c5..fd9eabff1 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { Stack } from '@mantine/core'; import { createToolSteps, ToolStepProvider } from './ToolStep'; import OperationButton from './OperationButton'; import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; @@ -57,7 +56,7 @@ export interface ToolFlowConfig { */ export function createToolFlow(config: ToolFlowConfig) { const steps = createToolSteps(); - + return ( {/* Files Step */} @@ -69,7 +68,7 @@ export function createToolFlow(config: ToolFlowConfig) { })} {/* Middle Steps */} - {config.steps.map((stepConfig, index) => + {config.steps.map((stepConfig, index) => steps.create(stepConfig.title, { isVisible: stepConfig.isVisible, isCollapsed: stepConfig.isCollapsed, @@ -99,4 +98,4 @@ export function createToolFlow(config: ToolFlowConfig) { })} ); -} \ No newline at end of file +} diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 953c7720f..20316b1a2 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -35,6 +35,7 @@ const initialViewerConfig: ViewerConfig = { const initialState: FileContextState = { activeFiles: [], processedFiles: new Map(), + pinnedFiles: new Set(), currentMode: 'pageEditor', currentView: 'fileEditor', // Legacy field currentTool: null, // Legacy field @@ -77,6 +78,9 @@ type FileContextAction = | { type: 'SET_UNSAVED_CHANGES'; payload: boolean } | { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null } | { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean } + | { type: 'PIN_FILE'; payload: File } + | { type: 'UNPIN_FILE'; payload: File } + | { type: 'CONSUME_FILES'; payload: { inputFiles: File[]; outputFiles: File[] } } | { type: 'RESET_CONTEXT' } | { type: 'LOAD_STATE'; payload: Partial }; @@ -317,6 +321,43 @@ function fileContextReducer(state: FileContextState, action: FileContextAction): showNavigationWarning: action.payload }; + case 'PIN_FILE': + return { + ...state, + pinnedFiles: new Set([...state.pinnedFiles, action.payload]) + }; + + case 'UNPIN_FILE': + const newPinnedFiles = new Set(state.pinnedFiles); + newPinnedFiles.delete(action.payload); + return { + ...state, + pinnedFiles: newPinnedFiles + }; + + case 'CONSUME_FILES': { + const { inputFiles, outputFiles } = action.payload; + const unpinnedInputFiles = inputFiles.filter(file => !state.pinnedFiles.has(file)); + + // Remove unpinned input files and add output files + const newActiveFiles = [ + ...state.activeFiles.filter(file => !unpinnedInputFiles.includes(file)), + ...outputFiles + ]; + + // Update processed files map - remove consumed files, keep pinned ones + const newProcessedFiles = new Map(state.processedFiles); + unpinnedInputFiles.forEach(file => { + newProcessedFiles.delete(file); + }); + + return { + ...state, + activeFiles: newActiveFiles, + processedFiles: newProcessedFiles + }; + } + case 'RESET_CONTEXT': return { ...initialState @@ -562,6 +603,46 @@ export function FileContextProvider({ dispatch({ type: 'CLEAR_SELECTIONS' }); }, [cleanupAllFiles]); + // File pinning functions + const pinFile = useCallback((file: File) => { + dispatch({ type: 'PIN_FILE', payload: file }); + }, []); + + const unpinFile = useCallback((file: File) => { + dispatch({ type: 'UNPIN_FILE', payload: file }); + }, []); + + const isFilePinned = useCallback((file: File): boolean => { + return state.pinnedFiles.has(file); + }, [state.pinnedFiles]); + + // File consumption function + const consumeFiles = useCallback(async (inputFiles: File[], outputFiles: File[]): Promise => { + dispatch({ type: 'CONSUME_FILES', payload: { inputFiles, outputFiles } }); + + // Store new output files if persistence is enabled + if (enablePersistence) { + for (const file of outputFiles) { + try { + const fileId = getFileId(file); + if (!fileId) { + try { + const thumbnail = await (thumbnailGenerationService as any).generateThumbnail(file); + const storedFile = await fileStorage.storeFile(file, thumbnail); + Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + } catch (thumbnailError) { + console.warn('Failed to generate thumbnail, storing without:', thumbnailError); + const storedFile = await fileStorage.storeFile(file); + Object.defineProperty(file, 'id', { value: storedFile.id, writable: false }); + } + } + } catch (error) { + console.error('Failed to store output file:', error); + } + } + } + }, [enablePersistence, state.pinnedFiles]); + // Navigation guard system functions const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges }); @@ -785,6 +866,10 @@ export function FileContextProvider({ removeFiles, replaceFile, clearAllFiles, + pinFile, + unpinFile, + isFilePinned, + consumeFiles, setCurrentMode, setCurrentView, setCurrentTool, diff --git a/frontend/src/contexts/FileSelectionContext.tsx b/frontend/src/contexts/FileSelectionContext.tsx index 2c79882b2..a0169b7ab 100644 --- a/frontend/src/contexts/FileSelectionContext.tsx +++ b/frontend/src/contexts/FileSelectionContext.tsx @@ -1,8 +1,9 @@ -import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; import { MaxFiles, FileSelectionContextValue } from '../types/tool'; +import { useFileContext } from './FileContext'; interface FileSelectionProviderProps { children: ReactNode; @@ -11,10 +12,23 @@ interface FileSelectionProviderProps { const FileSelectionContext = createContext(undefined); export function FileSelectionProvider({ children }: FileSelectionProviderProps) { + const { activeFiles } = useFileContext(); const [selectedFiles, setSelectedFiles] = useState([]); const [maxFiles, setMaxFiles] = useState(-1); const [isToolMode, setIsToolMode] = useState(false); + // Sync selected files with active files - remove any selected files that are no longer active + useEffect(() => { + if (selectedFiles.length > 0) { + const activeFileSet = new Set(activeFiles); + const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file)); + + if (validSelectedFiles.length !== selectedFiles.length) { + setSelectedFiles(validSelectedFiles); + } + } + }, [activeFiles, selectedFiles]); + const clearSelection = useCallback(() => { setSelectedFiles([]); }, []); diff --git a/frontend/src/hooks/tools/compress/useCompressOperation.ts b/frontend/src/hooks/tools/compress/useCompressOperation.ts index ebcac8b66..f4781e729 100644 --- a/frontend/src/hooks/tools/compress/useCompressOperation.ts +++ b/frontend/src/hooks/tools/compress/useCompressOperation.ts @@ -11,7 +11,7 @@ export interface CompressParameters { fileSizeUnit: 'KB' | 'MB'; } -const buildFormData = (parameters: CompressParameters, file: File): FormData => { +const buildFormData = (file: File, parameters: CompressParameters): FormData => { const formData = new FormData(); formData.append("fileInput", file); diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index b10aa069d..bdd2d93b2 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -104,7 +104,7 @@ export const useToolOperation = ( config: ToolOperationConfig ): ToolOperationHook => { const { t } = useTranslation(); - const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext(); + const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext(); // Composed hooks const { state, actions } = useToolState(); @@ -198,8 +198,8 @@ export const useToolOperation = ( actions.setThumbnails(thumbnails); actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); - // Add to file context - await addFiles(processedFiles); + // Consume input files and add output files (will replace unpinned inputs) + await consumeFiles(validFiles, processedFiles); markOperationApplied(fileId, operationId); } diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx index 19253b1eb..b727dbc87 100644 --- a/frontend/src/tools/Compress.tsx +++ b/frontend/src/tools/Compress.tsx @@ -29,7 +29,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { useEffect(() => { compressOperation.resetResults(); onPreviewFile?.(null); - }, [compressParams.parameters, selectedFiles]); + }, [compressParams.parameters]); const handleCompress = async () => { try { @@ -61,7 +61,6 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const hasFiles = selectedFiles.length > 0; const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null; - const filesCollapsed = hasFiles; const settingsCollapsed = !hasFiles || hasResults; return ( @@ -69,7 +68,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { {createToolFlow({ files: { selectedFiles, - isCollapsed: filesCollapsed + isCollapsed: hasFiles }, steps: [{ title: "Settings", @@ -86,6 +85,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }], executeButton: { text: t("compress.submit", "Compress"), + isVisible: !hasResults, loadingText: t("loading"), onClick: handleCompress, disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 555abdc4c..7d54c220f 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -55,6 +55,7 @@ export interface FileContextState { // Core file management activeFiles: File[]; processedFiles: Map; + pinnedFiles: Set; // Files that are pinned and won't be consumed // Current navigation state currentMode: ModeType; @@ -95,6 +96,14 @@ export interface FileContextActions { removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; replaceFile: (oldFileId: string, newFile: File) => Promise; clearAllFiles: () => void; + + // File pinning + pinFile: (file: File) => void; + unpinFile: (file: File) => void; + isFilePinned: (file: File) => boolean; + + // File consumption (replace unpinned files with outputs) + consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise; // Navigation setCurrentMode: (mode: ModeType) => void;