diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 93e099a2e..784b55f00 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core'; import DeleteIcon from '@mui/icons-material/Delete'; +import PushPinIcon from '@mui/icons-material/PushPin'; +import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import { getFileSize, getFileDate } from '../../utils/fileUtils'; import { FileMetadata } from '../../types/file'; @@ -8,8 +10,11 @@ interface FileListItemProps { file: FileMetadata; isSelected: boolean; isSupported: boolean; + isPinned?: boolean; onSelect: () => void; onRemove: () => void; + onPin?: () => void; + onUnpin?: () => void; onDoubleClick?: () => void; isLast?: boolean; } @@ -17,9 +22,12 @@ interface FileListItemProps { const FileListItem: React.FC = ({ file, isSelected, - isSupported, + isSupported, + isPinned = false, onSelect, - onRemove, + onRemove, + onPin, + onUnpin, onDoubleClick }) => { const [isHovered, setIsHovered] = useState(false); @@ -59,6 +67,36 @@ const FileListItem: React.FC = ({ {file.name} {getFileSize(file)} • {getFileDate(file)} + + {/* Pin button - always visible for pinned files, fades in/out on hover for unpinned */} + {(onPin || onUnpin) && ( + { + e.stopPropagation(); + if (isPinned) { + onUnpin?.(); + } else { + onPin?.(); + } + }} + style={{ + opacity: isPinned ? 1 : (isHovered ? 1 : 0), + transform: isPinned ? 'scale(1)' : (isHovered ? 'scale(1)' : 'scale(0.8)'), + transition: 'opacity 0.3s ease, transform 0.3s ease', + pointerEvents: isPinned ? 'auto' : (isHovered ? 'auto' : 'none') + }} + > + {isPinned ? ( + + ) : ( + + )} + + )} + {/* Delete button - fades in/out on hover */} void; + onFileClick?: (file: File | FileWithUrl | FileMetadata | null) => void; onPrevious?: () => void; onNext?: () => void; } diff --git a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx index 661947be2..0cf802a3a 100644 --- a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Box, Center, Image } from '@mantine/core'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; -import { FileWithUrl } from '../../../types/file'; +import { FileWithUrl, FileMetadata } from '../../../types/file'; export interface DocumentThumbnailProps { - file: File | FileWithUrl | null; + file: File | FileWithUrl | FileMetadata | null; thumbnail?: string | null; style?: React.CSSProperties; onClick?: () => void; diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 930ae709d..47c350097 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -88,6 +88,36 @@ export function FileContextProvider({ return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch); }, []); + // Helper to find FileId from File object + const findFileId = useCallback((file: File): FileId | undefined => { + return Object.keys(stateRef.current.files.byId).find(id => { + const storedFile = filesRef.current.get(id); + return storedFile && + storedFile.name === file.name && + storedFile.size === file.size && + storedFile.lastModified === file.lastModified; + }); + }, []); + + // File-to-ID wrapper functions for pinning + const pinFileWrapper = useCallback((file: File) => { + const fileId = findFileId(file); + if (fileId) { + baseActions.pinFile(fileId); + } else { + console.warn('File not found for pinning:', file.name); + } + }, [baseActions, findFileId]); + + const unpinFileWrapper = useCallback((file: File) => { + const fileId = findFileId(file); + if (fileId) { + baseActions.unpinFile(fileId); + } else { + console.warn('File not found for unpinning:', file.name); + } + }, [baseActions, findFileId]); + // Complete actions object const actions = useMemo(() => ({ ...baseActions, @@ -103,7 +133,9 @@ export function FileContextProvider({ filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); }, - // Pinned files functionality - isFilePinned available in selectors + // Pinned files functionality with File object wrappers + pinFile: pinFileWrapper, + unpinFile: unpinFileWrapper, consumeFiles: consumeFilesWrapper, setHasUnsavedChanges, trackBlobUrl: lifecycleManager.trackBlobUrl, @@ -118,7 +150,9 @@ export function FileContextProvider({ addStoredFiles, lifecycleManager, setHasUnsavedChanges, - consumeFilesWrapper + consumeFilesWrapper, + pinFileWrapper, + unpinFileWrapper ]); // Split context values to minimize re-renders diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 5151d5aca..b68354768 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -15,6 +15,7 @@ export const initialFileContextState: FileContextState = { ids: [], byId: {} }, + pinnedFiles: new Set(), ui: { selectedFileIds: [], selectedPageNumbers: [], @@ -153,6 +154,66 @@ export function fileContextReducer(state: FileContextState, action: FileContextA }; } + case 'PIN_FILE': { + const { fileId } = action.payload; + const newPinnedFiles = new Set(state.pinnedFiles); + newPinnedFiles.add(fileId); + + return { + ...state, + pinnedFiles: newPinnedFiles + }; + } + + case 'UNPIN_FILE': { + const { fileId } = action.payload; + const newPinnedFiles = new Set(state.pinnedFiles); + newPinnedFiles.delete(fileId); + + return { + ...state, + pinnedFiles: newPinnedFiles + }; + } + + case 'CONSUME_FILES': { + const { inputFileIds, outputFileRecords } = action.payload; + + // Only remove unpinned input files + const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id)); + const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id)); + + // Remove unpinned files from state + const newById = { ...state.files.byId }; + unpinnedInputIds.forEach(id => { + delete newById[id]; + }); + + // Add output files + const outputIds: FileId[] = []; + outputFileRecords.forEach(record => { + if (!newById[record.id]) { + outputIds.push(record.id); + newById[record.id] = record; + } + }); + + // Clear selections that reference removed files + const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id)); + + return { + ...state, + files: { + ids: [...remainingIds, ...outputIds], + byId: newById + }, + ui: { + ...state.ui, + selectedFileIds: validSelectedFileIds + } + }; + } + case 'RESET_CONTEXT': { // Reset UI state to clean slate (resource cleanup handled by lifecycle manager) return { ...initialFileContextState }; diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 4953cdd9a..b99b84d02 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -212,6 +212,62 @@ export async function addFiles( return addedFiles; } +/** + * Consume files helper - replace unpinned input files with output files + */ +export async function consumeFiles( + inputFileIds: FileId[], + outputFiles: File[], + stateRef: React.MutableRefObject, + filesRef: React.MutableRefObject>, + dispatch: React.Dispatch +): Promise { + if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`); + + // Process output files through the 'processed' path to generate thumbnails + const outputFileRecords = await Promise.all( + outputFiles.map(async (file) => { + const fileId = createFileId(); + filesRef.current.set(fileId, file); + + // Generate thumbnail and page count for output file + let thumbnail: string | undefined; + let pageCount: number = 1; + + try { + if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`); + const result = await generateThumbnailWithMetadata(file); + thumbnail = result.thumbnail; + pageCount = result.pageCount; + } catch (error) { + if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error); + } + + const record = toFileRecord(file, fileId); + if (thumbnail) { + record.thumbnailUrl = thumbnail; + } + + if (pageCount > 0) { + record.processedFile = createProcessedFile(pageCount, thumbnail); + } + + return record; + }) + ); + + // Dispatch the consume action + dispatch({ + type: 'CONSUME_FILES', + payload: { + inputFileIds, + outputFileRecords + } + }); + + if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`); +} + /** * Action factory functions */ @@ -221,5 +277,7 @@ export const createFileActions = (dispatch: React.Dispatch) = clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }), setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }), setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }), + pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }), + unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }), resetContext: () => dispatch({ type: 'RESET_CONTEXT' }) }); \ No newline at end of file diff --git a/frontend/src/contexts/file/fileHooks.ts b/frontend/src/contexts/file/fileHooks.ts index 617babfe2..cf04c1f09 100644 --- a/frontend/src/contexts/file/fileHooks.ts +++ b/frontend/src/contexts/file/fileHooks.ts @@ -144,14 +144,32 @@ export function useSelectedFiles(): { files: File[]; records: FileRecord[]; file * Used by tools for core file context functionality */ export function useFileContext() { + const { state, selectors } = useFileState(); const { actions } = useFileActions(); return useMemo(() => ({ + // Lifecycle management trackBlobUrl: actions.trackBlobUrl, trackPdfDocument: actions.trackPdfDocument, scheduleCleanup: actions.scheduleCleanup, - setUnsavedChanges: actions.setHasUnsavedChanges - }), [actions]); + setUnsavedChanges: actions.setHasUnsavedChanges, + + // File management + addFiles: actions.addFiles, + consumeFiles: actions.consumeFiles, + recordOperation: (fileId: string, operation: any) => {}, // TODO: Implement operation tracking + markOperationApplied: (fileId: string, operationId: string) => {}, // TODO: Implement operation tracking + markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // TODO: Implement operation tracking + + // Pinned files + pinnedFiles: state.pinnedFiles, + pinFile: actions.pinFile, + unpinFile: actions.unpinFile, + isFilePinned: selectors.isFilePinned, + + // Active files + activeFiles: selectors.getFiles() + }), [state, selectors, actions]); } /** diff --git a/frontend/src/contexts/file/fileSelectors.ts b/frontend/src/contexts/file/fileSelectors.ts index 268f55b1d..55ef48640 100644 --- a/frontend/src/contexts/file/fileSelectors.ts +++ b/frontend/src/contexts/file/fileSelectors.ts @@ -45,6 +45,35 @@ export function createFileSelectors( .filter(Boolean); }, + // Pinned files selectors + getPinnedFileIds: () => { + return Array.from(stateRef.current.pinnedFiles); + }, + + getPinnedFiles: () => { + return Array.from(stateRef.current.pinnedFiles) + .map(id => filesRef.current.get(id)) + .filter(Boolean) as File[]; + }, + + getPinnedFileRecords: () => { + return Array.from(stateRef.current.pinnedFiles) + .map(id => stateRef.current.files.byId[id]) + .filter(Boolean); + }, + + isFilePinned: (file: File) => { + // Find FileId by matching File object properties + const fileId = Object.keys(stateRef.current.files.byId).find(id => { + const storedFile = filesRef.current.get(id); + return storedFile && + storedFile.name === file.name && + storedFile.size === file.size && + storedFile.lastModified === file.lastModified; + }); + return fileId ? stateRef.current.pinnedFiles.has(fileId) : false; + }, + // Stable signature for effects - prevents unnecessary re-renders getFilesSignature: () => { return stateRef.current.files.ids diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 623b93d84..2c78cb153 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, consumeFiles } = useFileContext(); + const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext(); // Composed hooks const { state, actions } = useToolState(); @@ -198,8 +198,8 @@ export const useToolOperation = ( actions.setThumbnails(thumbnails); actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); - // Consume input files and add output files (will replace unpinned inputs) - await consumeFiles(validFiles, processedFiles); + // Add processed files to the file context + await addFiles(processedFiles); markOperationApplied(fileId, operationId); } diff --git a/frontend/src/tools/AddPassword.tsx b/frontend/src/tools/AddPassword.tsx index e9fa320db..68b175c7a 100644 --- a/frontend/src/tools/AddPassword.tsx +++ b/frontend/src/tools/AddPassword.tsx @@ -17,7 +17,6 @@ import { BaseToolProps } from "../types/tool"; const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); const { selectedFiles } = useToolFileSelection(); - // const setCurrentMode = (mode) => console.log('Navigate to:', mode); // TODO: Hook up to URL routing const [collapsedPermissions, setCollapsedPermissions] = useState(true); @@ -50,7 +49,6 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "addPassword"); - setCurrentMode("viewer"); }; const handleSettingsReset = () => { diff --git a/frontend/src/tools/AddWatermark.tsx b/frontend/src/tools/AddWatermark.tsx index 3f2357a7e..bd3b9b3a0 100644 --- a/frontend/src/tools/AddWatermark.tsx +++ b/frontend/src/tools/AddWatermark.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useToolFileSelection } from "../contexts/FileContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -25,7 +24,6 @@ import { BaseToolProps } from "../types/tool"; const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); const { selectedFiles } = useToolFileSelection(); const [collapsedType, setCollapsedType] = useState(false); @@ -71,13 +69,11 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "watermark"); - setCurrentMode("viewer"); }; const handleSettingsReset = () => { watermarkOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("watermark"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/tools/RemovePassword.tsx b/frontend/src/tools/RemovePassword.tsx index 42f22ba9c..47127cdad 100644 --- a/frontend/src/tools/RemovePassword.tsx +++ b/frontend/src/tools/RemovePassword.tsx @@ -1,8 +1,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useEndpointEnabled } from "../hooks/useEndpointConfig"; -import { useFileContext } from "../contexts/FileContext"; -import { useToolFileSelection } from "../contexts/FileSelectionContext"; +import { useToolFileSelection } from "../contexts/FileContext"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; @@ -15,7 +14,6 @@ import { BaseToolProps } from "../types/tool"; const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { setCurrentMode } = useFileContext(); const { selectedFiles } = useToolFileSelection(); const removePasswordParams = useRemovePasswordParameters(); @@ -46,13 +44,11 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) = const handleThumbnailClick = (file: File) => { onPreviewFile?.(file); sessionStorage.setItem("previousMode", "removePassword"); - setCurrentMode("viewer"); }; const handleSettingsReset = () => { removePasswordOperation.resetResults(); onPreviewFile?.(null); - setCurrentMode("removePassword"); }; const hasFiles = selectedFiles.length > 0; diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts index c11d3a2b4..4af56d8e8 100644 --- a/frontend/src/utils/thumbnailUtils.ts +++ b/frontend/src/utils/thumbnailUtils.ts @@ -16,15 +16,15 @@ interface ColorScheme { } /** - * Calculate thumbnail scale based on file size + * Calculate thumbnail scale based on file size (modern 2024 scaling) */ export function calculateScaleFromFileSize(fileSize: number): number { const MB = 1024 * 1024; - if (fileSize < 1 * MB) return 0.6; - if (fileSize < 5 * MB) return 0.4; - if (fileSize < 15 * MB) return 0.3; - if (fileSize < 30 * MB) return 0.2; - return 0.15; + if (fileSize < 10 * MB) return 1.0; // Full quality for small files + if (fileSize < 50 * MB) return 0.8; // High quality for common file sizes + if (fileSize < 200 * MB) return 0.6; // Good quality for typical large files + if (fileSize < 500 * MB) return 0.4; // Readable quality for large but manageable files + return 0.3; // Still usable quality, not tiny } /** @@ -341,9 +341,21 @@ export async function generateThumbnailForFile(file: File): Promise