Merge branch 'V2' into urls

This commit is contained in:
ConnorYoh 2025-09-05 12:24:57 +01:00 committed by GitHub
commit 343d7b5b8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
127 changed files with 1769 additions and 1220 deletions

View File

@ -24,7 +24,7 @@ indent_size = 2
insert_final_newline = false insert_final_newline = false
trim_trailing_whitespace = false trim_trailing_whitespace = false
[{*.js,*.jsx,*.ts,*.tsx}] [{*.js,*.jsx,*.mjs,*.ts,*.tsx}]
indent_size = 2 indent_size = 2
[*.css] [*.css]

View File

@ -147,6 +147,8 @@ jobs:
cache-dependency-path: frontend/package-lock.json cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies - name: Install frontend dependencies
run: cd frontend && npm ci run: cd frontend && npm ci
- name: Lint frontend
run: cd frontend && npm run lint
- name: Build frontend - name: Build frontend
run: cd frontend && npm run build run: cd frontend && npm run build
- name: Run frontend tests - name: Run frontend tests

View File

@ -19,5 +19,6 @@
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing "yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
"stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting "stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting
"redhat.vscode-yaml", // YAML extension for Visual Studio Code "redhat.vscode-yaml", // YAML extension for Visual Studio Code
"dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting
] ]
} }

View File

@ -0,0 +1,42 @@
// @ts-check
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
{
ignores: [
"dist", // Contains 3rd party code
"public", // Contains 3rd party code
],
},
{
rules: {
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
{
"args": "all", // All function args must be used (or explicitly ignored)
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
}
);

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,7 @@
"predev": "npm run generate-icons", "predev": "npm run generate-icons",
"dev": "npx tsc --noEmit && vite", "dev": "npx tsc --noEmit && vite",
"prebuild": "npm run generate-icons", "prebuild": "npm run generate-icons",
"lint": "npx eslint",
"build": "npx tsc --noEmit && vite build", "build": "npx tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@ -72,6 +73,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.34.0",
"@iconify-json/material-symbols": "^1.2.33", "@iconify-json/material-symbols": "^1.2.33",
"@iconify/utils": "^3.0.1", "@iconify/utils": "^3.0.1",
"@playwright/test": "^1.40.0", "@playwright/test": "^1.40.0",
@ -80,6 +82,7 @@
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0", "@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^1.0.0", "@vitest/coverage-v8": "^1.0.0",
"eslint": "^9.34.0",
"jsdom": "^23.0.0", "jsdom": "^23.0.0",
"license-checker": "^25.0.1", "license-checker": "^25.0.1",
"madge": "^8.0.0", "madge": "^8.0.0",
@ -87,7 +90,8 @@
"postcss-cli": "^11.0.1", "postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"typescript": "^5.8.3", "typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitest": "^1.0.0" "vitest": "^1.0.0"
} }

View File

@ -107,7 +107,7 @@ async function main() {
needsRegeneration = false; needsRegeneration = false;
info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`);
} }
} catch (error) { } catch {
// If we can't parse existing file, regenerate // If we can't parse existing file, regenerate
needsRegeneration = true; needsRegeneration = true;
} }

View File

@ -24,7 +24,7 @@ try {
// Install license-checker if not present // Install license-checker if not present
try { try {
require.resolve('license-checker'); require.resolve('license-checker');
} catch (e) { } catch {
console.log('📦 Installing license-checker...'); console.log('📦 Installing license-checker...');
execSync('npm install --save-dev license-checker', { stdio: 'inherit' }); execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
} }
@ -224,7 +224,7 @@ function getLicenseUrl(licenseType) {
// Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)" // Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)"
if (licenseType.includes('AND') || licenseType.includes('OR')) { if (licenseType.includes('AND') || licenseType.includes('OR')) {
// Extract the first license from compound expressions for URL // Extract the first license from compound expressions for URL
const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/); const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/);
if (match && licenseUrls[match[1]]) { if (match && licenseUrls[match[1]]) {
return licenseUrls[match[1]]; return licenseUrls[match[1]];
} }

View File

@ -14,6 +14,9 @@ import "./styles/cookieconsent.css";
import "./index.css"; import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext"; import { RightRailProvider } from "./contexts/RightRailContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
// Loading component for i18next suspense // Loading component for i18next suspense
const LoadingFallback = () => ( const LoadingFallback = () => (
<div <div

View File

@ -4,7 +4,6 @@ import { Dropzone } from '@mantine/dropzone';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { useFileManager } from '../hooks/useFileManager'; import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext'; import { useFilesModalContext } from '../contexts/FilesModalContext';
import { createFileId } from '../types/fileContext';
import { Tool } from '../types/tool'; import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout'; import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout'; import DesktopLayout from './fileManager/DesktopLayout';
@ -21,13 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
// Wrapper for storeFile that generates UUID
const storeFileWithId = useCallback(async (file: File) => {
const fileId = createFileId(); // Generate UUID for storage
return await storeFile(file, fileId);
}, [storeFile]);
// File management handlers // File management handlers
const isFileSupported = useCallback((fileName: string) => { const isFileSupported = useCallback((fileName: string) => {

View File

@ -1,42 +1,28 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useRef, useMemo } from 'react';
import { import {
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext'; import { useNavigationActions } from '../../contexts/NavigationContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService'; import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils'; import { detectFileExtension } from '../../utils/fileUtils';
import styles from './FileEditor.module.css';
import FileEditorThumbnail from './FileEditorThumbnail'; import FileEditorThumbnail from './FileEditorThumbnail';
import FilePickerModal from '../shared/FilePickerModal'; import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId } from '../../types/file'; import { FileId, StirlingFile } from '../../types/fileContext';
interface FileEditorProps { interface FileEditorProps {
onOpenPageEditor?: (file: File) => void; onOpenPageEditor?: () => void;
onMergeFiles?: (files: File[]) => void; onMergeFiles?: (files: StirlingFile[]) => void;
toolMode?: boolean; toolMode?: boolean;
showUpload?: boolean;
showBulkActions?: boolean;
supportedExtensions?: string[]; supportedExtensions?: string[];
} }
const FileEditor = ({ const FileEditor = ({
onOpenPageEditor,
onMergeFiles,
toolMode = false, toolMode = false,
showUpload = true,
showBulkActions = true,
supportedExtensions = ["pdf"] supportedExtensions = ["pdf"]
}: FileEditorProps) => { }: FileEditorProps) => {
const { t } = useTranslation();
// Utility function to check if a file extension is supported // Utility function to check if a file extension is supported
const isFileSupported = useCallback((fileName: string): boolean => { const isFileSupported = useCallback((fileName: string): boolean => {
@ -49,13 +35,10 @@ const FileEditor = ({
const { addFiles, removeFiles, reorderFiles } = useFileManagement(); const { addFiles, removeFiles, reorderFiles } = useFileManagement();
// Extract needed values from state (memoized to prevent infinite loops) // Extract needed values from state (memoized to prevent infinite loops)
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds; const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing;
// Get the real context actions // Get navigation actions
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions(); const { actions: navActions } = useNavigationActions();
// Get file selection context // Get file selection context
@ -92,10 +75,10 @@ const FileEditor = ({
const contextSelectedIdsRef = useRef<FileId[]>([]); const contextSelectedIdsRef = useRef<FileId[]>([]);
contextSelectedIdsRef.current = contextSelectedIds; contextSelectedIdsRef.current = contextSelectedIds;
// Use activeFileRecords directly - no conversion needed // Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds; const localSelectedIds = contextSelectedIds;
// Helper to convert FileRecord to FileThumbnail format // Helper to convert StirlingFileStub to FileThumbnail format
const recordToFileItem = useCallback((record: any) => { const recordToFileItem = useCallback((record: any) => {
const file = selectors.getFile(record.id); const file = selectors.getFile(record.id);
if (!file) return null; if (!file) return null;
@ -161,29 +144,9 @@ const FileEditor = ({
if (extractionResult.success) { if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles); allExtractedFiles.push(...extractionResult.extractedFiles);
// Record ZIP extraction operation if (extractionResult.errors.length > 0) {
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; errors.push(...extractionResult.errors);
const operation: FileOperation = {
id: operationId,
type: 'convert',
timestamp: Date.now(),
fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */,
status: 'pending',
metadata: {
originalFileName: file.name,
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
fileSize: file.size,
parameters: {
extractionType: 'zip',
extractedCount: extractionResult.extractedCount,
totalFiles: extractionResult.totalFiles
}
} }
};
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
} else { } else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
} }
@ -213,25 +176,6 @@ const FileEditor = ({
// Process all extracted files // Process all extracted files
if (allExtractedFiles.length > 0) { if (allExtractedFiles.length > 0) {
// Record upload operations for PDF files
for (const file of allExtractedFiles) {
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'upload',
timestamp: Date.now(),
fileIds: [file.name as FileId /* This doesn't seem right */],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
uploadMethod: 'drag-drop'
}
}
};
}
// Add files to context (they will be processed automatically) // Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles); await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`); setStatus(`Added ${allExtractedFiles.length} files`);
@ -252,27 +196,10 @@ const FileEditor = ({
} }
}, [addFiles]); }, [addFiles]);
const selectAll = useCallback(() => {
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
}, [activeFileRecords, setSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFileRecords.length === 0) return;
// Remove all files from context but keep in storage
const allFileIds = activeFileRecords.map(record => record.id);
removeFiles(allFileIds, false); // false = keep in storage
// Clear selections
setSelectedFiles([]);
}, [activeFileRecords, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: FileId) => { const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current; const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeFileRecords.find(r => r.id === fileId); const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId);
if (!targetRecord) return; if (!targetRecord) return;
const contextFileId = fileId; // No need to create a new ID const contextFileId = fileId; // No need to create a new ID
@ -302,21 +229,12 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action) // Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection); setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]); }, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setSelectedFiles([]);
}
return newMode;
});
}, [setSelectedFiles]);
// File reordering handler for drag and drop // File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeFileRecords.map(r => r.id); const currentIds = activeStirlingFileStubs.map(r => r.id);
// Find indices // Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId); const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
@ -368,71 +286,34 @@ const FileEditor = ({
// Update status // Update status
const moveCount = filesToMove.length; const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeFileRecords, reorderFiles, setStatus]); }, [activeStirlingFileStubs, reorderFiles, setStatus]);
// File operations using context // File operations using context
const handleDeleteFile = useCallback((fileId: FileId) => { const handleDeleteFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId); const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null; const file = record ? selectors.getFile(record.id) : null;
if (record && file) { if (record && file) {
// Record close operation
const fileName = file.name;
const contextFileId = record.id;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [fileName as FileId /* FIX ME: This doesn't seem right */],
status: 'pending',
metadata: {
originalFileName: fileName,
fileSize: record.size,
parameters: {
action: 'close',
reason: 'user_request'
}
}
};
// Remove file from context but keep in storage (close, don't delete) // Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false); removeFiles([contextFileId], false);
// Remove from context selections // Remove from context selections
const currentSelected = selectedFileIds.filter(id => id !== contextFileId); const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected); setSelectedFiles(currentSelected);
} }
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]); }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: FileId) => { const handleViewFile = useCallback((fileId: FileId) => {
const record = activeFileRecords.find(r => r.id === fileId); const record = activeStirlingFileStubs.find(r => r.id === fileId);
if (record) { if (record) {
// Set the file as selected in context and switch to viewer for preview // Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId]); setSelectedFiles([fileId]);
navActions.setWorkbench('viewer'); navActions.setWorkbench('viewer');
} }
}, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]); }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]);
const handleMergeFromHere = useCallback((fileId: FileId) => {
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
if (startIndex === -1) return;
const recordsToMerge = activeFileRecords.slice(startIndex);
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [activeFileRecords, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: FileId) => {
const file = selectors.getFile(fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file);
}
}, [selectors, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return; if (selectedFiles.length === 0) return;
@ -467,7 +348,7 @@ const FileEditor = ({
<Box p="md" pt="xl"> <Box p="md" pt="xl">
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? ( {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
<Center h="60vh"> <Center h="60vh">
<Stack align="center" gap="md"> <Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text> <Text size="lg" c="dimmed">📁</Text>
@ -475,7 +356,7 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text> <Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack> </Stack>
</Center> </Center>
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? ( ) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
<Box> <Box>
<SkeletonLoader type="controls" /> <SkeletonLoader type="controls" />
@ -522,7 +403,7 @@ const FileEditor = ({
pointerEvents: 'auto' pointerEvents: 'auto'
}} }}
> >
{activeFileRecords.map((record, index) => { {activeStirlingFileStubs.map((record, index) => {
const fileItem = recordToFileItem(record); const fileItem = recordToFileItem(record);
if (!fileItem) return null; if (!fileItem) return null;
@ -531,7 +412,7 @@ const FileEditor = ({
key={record.id} key={record.id}
file={fileItem} file={fileItem}
index={index} index={index}
totalFiles={activeFileRecords.length} totalFiles={activeStirlingFileStubs.length}
selectedFiles={localSelectedIds} selectedFiles={localSelectedIds}
selectionMode={selectionMode} selectionMode={selectionMode}
onToggleFile={toggleFile} onToggleFile={toggleFile}

View File

@ -44,7 +44,6 @@ const FileEditorThumbnail = ({
selectedFiles, selectedFiles,
onToggleFile, onToggleFile,
onDeleteFile, onDeleteFile,
onViewFile,
onSetStatus, onSetStatus,
onReorderFiles, onReorderFiles,
onDownloadFile, onDownloadFile,
@ -61,8 +60,8 @@ const FileEditorThumbnail = ({
// Resolve the actual File object for pin/unpin operations // Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => { const actualFile = useMemo(() => {
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.name, file.size]); }, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false; const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => { const downloadSelectedFile = useCallback(() => {

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { Stack, Button, Box } from '@mantine/core'; import { Stack, Button, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail'; import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
@ -11,27 +11,26 @@ interface FileDetailsProps {
compact?: boolean; compact?: boolean;
} }
const FileDetails: React.FC<FileDetailsProps> = ({ const FileDetails: React.FC<FileDetailsProps> = ({
compact = false compact = false
}) => { }) => {
const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
const { t } = useTranslation(); const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0); const [currentFileIndex, setCurrentFileIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
// Get the currently displayed file // Get the currently displayed file
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null; const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
const hasSelection = selectedFiles.length > 0; const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = selectedFiles.length > 1;
// Use IndexedDB hook for the current file // Use IndexedDB hook for the current file
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile); const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
// Get thumbnail for current file // Get thumbnail for current file
const getCurrentThumbnail = () => { const getCurrentThumbnail = () => {
return currentThumbnail; return currentThumbnail;
}; };
const handlePrevious = () => { const handlePrevious = () => {
if (isAnimating) return; if (isAnimating) return;
setIsAnimating(true); setIsAnimating(true);
@ -40,7 +39,7 @@ const FileDetails: React.FC<FileDetailsProps> = ({
setIsAnimating(false); setIsAnimating(false);
}, 150); }, 150);
}; };
const handleNext = () => { const handleNext = () => {
if (isAnimating) return; if (isAnimating) return;
setIsAnimating(true); setIsAnimating(true);
@ -49,14 +48,14 @@ const FileDetails: React.FC<FileDetailsProps> = ({
setIsAnimating(false); setIsAnimating(false);
}, 150); }, 150);
}; };
// Reset index when selection changes // Reset index when selection changes
React.useEffect(() => { React.useEffect(() => {
if (currentFileIndex >= selectedFiles.length) { if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0); setCurrentFileIndex(0);
} }
}, [selectedFiles.length, currentFileIndex]); }, [selectedFiles.length, currentFileIndex]);
if (compact) { if (compact) {
return ( return (
<CompactFileDetails <CompactFileDetails
@ -88,26 +87,26 @@ const FileDetails: React.FC<FileDetailsProps> = ({
onNext={handleNext} onNext={handleNext}
/> />
</Box> </Box>
{/* Section 2: File Details */} {/* Section 2: File Details */}
<FileInfoCard <FileInfoCard
currentFile={currentFile} currentFile={currentFile}
modalHeight={modalHeight} modalHeight={modalHeight}
/> />
<Button <Button
size="md" size="md"
mb="xl" mb="xl"
onClick={onOpenFiles} onClick={onOpenFiles}
disabled={!hasSelection} disabled={!hasSelection}
fullWidth fullWidth
style={{ style={{
flexShrink: 0, flexShrink: 0,
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)', backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white' color: 'white'
}} }}
> >
{selectedFiles.length > 1 {selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`) ? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File') : t('fileManager.openFile', 'Open File')
} }
@ -116,4 +115,4 @@ const FileDetails: React.FC<FileDetailsProps> = ({
); );
}; };
export default FileDetails; export default FileDetails;

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Stack, Box } from '@mantine/core'; import { Box } from '@mantine/core';
import FileSourceButtons from './FileSourceButtons'; import FileSourceButtons from './FileSourceButtons';
import FileDetails from './FileDetails'; import FileDetails from './FileDetails';
import SearchInput from './SearchInput'; import SearchInput from './SearchInput';
@ -19,14 +19,14 @@ const MobileLayout: React.FC = () => {
const calculateFileListHeight = () => { const calculateFileListHeight = () => {
// Base modal height minus padding and gaps // Base modal height minus padding and gaps
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
// Estimate heights of fixed components // Estimate heights of fixed components
const fileSourceHeight = '3rem'; // FileSourceButtons height const fileSourceHeight = '3rem'; // FileSourceButtons height
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom) const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`; return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
}; };
@ -36,15 +36,15 @@ const MobileLayout: React.FC = () => {
<Box style={{ flexShrink: 0 }}> <Box style={{ flexShrink: 0 }}>
<FileSourceButtons horizontal={true} /> <FileSourceButtons horizontal={true} />
</Box> </Box>
<Box style={{ flexShrink: 0 }}> <Box style={{ flexShrink: 0 }}>
<FileDetails compact={true} /> <FileDetails compact={true} />
</Box> </Box>
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */} {/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
<Box style={{ <Box style={{
flex: 1, flex: 1,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)', backgroundColor: 'var(--bg-file-list)',
borderRadius: '0.5rem', borderRadius: '0.5rem',
@ -54,13 +54,13 @@ const MobileLayout: React.FC = () => {
}}> }}>
{activeSource === 'recent' && ( {activeSource === 'recent' && (
<> <>
<Box style={{ <Box style={{
flexShrink: 0, flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)' borderBottom: '1px solid var(--mantine-color-gray-2)'
}}> }}>
<SearchInput /> <SearchInput />
</Box> </Box>
<Box style={{ <Box style={{
flexShrink: 0, flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)' borderBottom: '1px solid var(--mantine-color-gray-2)'
}}> }}>
@ -68,11 +68,11 @@ const MobileLayout: React.FC = () => {
</Box> </Box>
</> </>
)} )}
<Box style={{ flex: 1, minHeight: 0 }}> <Box style={{ flex: 1, minHeight: 0 }}>
<FileListArea <FileListArea
scrollAreaHeight={calculateFileListHeight()} scrollAreaHeight={calculateFileListHeight()}
scrollAreaStyle={{ scrollAreaStyle={{
height: calculateFileListHeight(), height: calculateFileListHeight(),
maxHeight: '60vh', maxHeight: '60vh',
minHeight: '9.375rem', minHeight: '9.375rem',
@ -83,11 +83,11 @@ const MobileLayout: React.FC = () => {
/> />
</Box> </Box>
</Box> </Box>
{/* Hidden file input for local file selection */} {/* Hidden file input for local file selection */}
<HiddenFileInput /> <HiddenFileInput />
</Box> </Box>
); );
}; };
export default MobileLayout; export default MobileLayout;

View File

@ -1,181 +0,0 @@
import React from 'react';
import {
Stack,
Paper,
Text,
Badge,
Group,
Collapse,
Box,
ScrollArea,
Code,
Divider
} from '@mantine/core';
// FileContext no longer needed - these were stub functions anyway
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
import { FileId } from '../../types/file';
interface FileOperationHistoryProps {
fileId: FileId;
showOnlyApplied?: boolean;
maxHeight?: number;
}
const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
fileId,
showOnlyApplied = false,
maxHeight = 400
}) => {
// These were stub functions in the old context - replace with empty stubs
const getFileHistory = (fileId: FileId) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
const getAppliedOperations = (fileId: FileId) => [];
const history = getFileHistory(fileId);
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const getOperationIcon = (type: string) => {
switch (type) {
case 'split': return '✂️';
case 'merge': return '🔗';
case 'compress': return '🗜️';
case 'rotate': return '🔄';
case 'delete': return '🗑️';
case 'move': return '↕️';
case 'insert': return '📄';
case 'upload': return '⬆️';
case 'add': return '';
case 'remove': return '';
case 'replace': return '🔄';
case 'convert': return '🔄';
default: return '⚙️';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'applied': return 'green';
case 'failed': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
};
const renderOperationDetails = (operation: FileOperation) => {
if ('metadata' in operation && operation.metadata) {
const { metadata } = operation;
return (
<Box mt="xs">
{metadata.parameters && (
<Text size="xs" c="dimmed">
Parameters: <Code>{JSON.stringify(metadata.parameters, null, 2)}</Code>
</Text>
)}
{metadata.originalFileName && (
<Text size="xs" c="dimmed">
Original file: {metadata.originalFileName}
</Text>
)}
{metadata.outputFileNames && (
<Text size="xs" c="dimmed">
Output files: {metadata.outputFileNames.join(', ')}
</Text>
)}
{metadata.fileSize && (
<Text size="xs" c="dimmed">
File size: {(metadata.fileSize / 1024 / 1024).toFixed(2)} MB
</Text>
)}
{metadata.pageCount && (
<Text size="xs" c="dimmed">
Pages: {metadata.pageCount}
</Text>
)}
{metadata.error && (
<Text size="xs" c="red">
Error: {metadata.error}
</Text>
)}
</Box>
);
}
return null;
};
if (!history || operations.length === 0) {
return (
<Paper p="md" withBorder>
<Text c="dimmed" ta="center">
{showOnlyApplied ? 'No applied operations found' : 'No operation history available'}
</Text>
</Paper>
);
}
return (
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Text fw={500}>
{showOnlyApplied ? 'Applied Operations' : 'Operation History'}
</Text>
<Badge variant="light" color="blue">
{operations.length} operations
</Badge>
</Group>
<ScrollArea h={maxHeight}>
<Stack gap="sm">
{operations.map((operation, index) => (
<Paper key={operation.id} p="sm" withBorder radius="sm" bg="gray.0">
<Group justify="space-between" align="start">
<Group gap="xs">
<Text span size="lg">
{getOperationIcon(operation.type)}
</Text>
<Box>
<Text fw={500} size="sm">
{operation.type.charAt(0).toUpperCase() + operation.type.slice(1)}
</Text>
<Text size="xs" c="dimmed">
{formatTimestamp(operation.timestamp)}
</Text>
</Box>
</Group>
<Badge
variant="filled"
color={getStatusColor(operation.status)}
size="sm"
>
{operation.status}
</Badge>
</Group>
{renderOperationDetails(operation)}
{index < operations.length - 1 && <Divider mt="sm" />}
</Paper>
))}
</Stack>
</ScrollArea>
{history && (
<Group justify="space-between" mt="sm" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
<Text size="xs" c="dimmed">
Created: {formatTimestamp(history.createdAt)}
</Text>
<Text size="xs" c="dimmed">
Last modified: {formatTimestamp(history.lastModified)}
</Text>
</Group>
)}
</Paper>
);
};
export default FileOperationHistory;

View File

@ -1,6 +1,4 @@
import React from 'react';
import { Box } from '@mantine/core'; import { Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileHandler } from '../../hooks/useFileHandler';
@ -19,7 +17,6 @@ import Footer from '../shared/Footer';
// No props needed - component uses contexts directly // No props needed - component uses contexts directly
export default function Workbench() { export default function Workbench() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling // Use context-based hooks to eliminate all prop drilling
@ -78,11 +75,9 @@ export default function Workbench() {
return ( return (
<FileEditor <FileEditor
toolMode={!!selectedToolId} toolMode={!!selectedToolId}
showUpload={true}
showBulkActions={!selectedToolId}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolId && { {...(!selectedToolId && {
onOpenPageEditor: (file) => { onOpenPageEditor: () => {
setCurrentView("pageEditor"); setCurrentView("pageEditor");
}, },
onMergeFiles: (filesToMerge) => { onMergeFiles: (filesToMerge) => {

View File

@ -1,8 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Box } from '@mantine/core'; import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
import { GRID_CONSTANTS } from './constants'; import { GRID_CONSTANTS } from './constants';
interface DragDropItem { interface DragDropItem {
@ -22,65 +20,60 @@ interface DragDropGridProps<T extends DragDropItem> {
const DragDropGrid = <T extends DragDropItem>({ const DragDropGrid = <T extends DragDropItem>({
items, items,
selectedItems,
selectionMode,
isAnimating,
onReorderPages,
renderItem, renderItem,
renderSplitMarker,
}: DragDropGridProps<T>) => { }: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Responsive grid configuration // Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4); const [itemsPerRow, setItemsPerRow] = useState(4);
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL; const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
// Calculate items per row based on container width // Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => { const calculateItemsPerRow = useCallback(() => {
if (!containerRef.current) return 4; // Default fallback if (!containerRef.current) return 4; // Default fallback
const containerWidth = containerRef.current.offsetWidth; const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4; // Container not measured yet if (containerWidth === 0) return 4; // Container not measured yet
// Convert rem to pixels for calculation // Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
// Calculate how many items fit: (width - gap) / (itemWidth + gap) // Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP; const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const calculated = Math.floor(availableWidth / itemWithGap); const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row return Math.max(1, calculated); // At least 1 item per row
}, []); }, []);
// Update items per row when container resizes // Update items per row when container resizes
useEffect(() => { useEffect(() => {
const updateLayout = () => { const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow(); const newItemsPerRow = calculateItemsPerRow();
setItemsPerRow(newItemsPerRow); setItemsPerRow(newItemsPerRow);
}; };
// Initial calculation // Initial calculation
updateLayout(); updateLayout();
// Listen for window resize // Listen for window resize
window.addEventListener('resize', updateLayout); window.addEventListener('resize', updateLayout);
// Use ResizeObserver for container size changes // Use ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(updateLayout); const resizeObserver = new ResizeObserver(updateLayout);
if (containerRef.current) { if (containerRef.current) {
resizeObserver.observe(containerRef.current); resizeObserver.observe(containerRef.current);
} }
return () => { return () => {
window.removeEventListener('resize', updateLayout); window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect(); resizeObserver.disconnect();
}; };
}, [calculateItemsPerRow]); }, [calculateItemsPerRow]);
// Virtualization with react-virtual library // Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow), count: Math.ceil(items.length / itemsPerRow),
@ -92,8 +85,6 @@ const DragDropGrid = <T extends DragDropItem>({
overscan: OVERSCAN, overscan: OVERSCAN,
}); });
// Calculate optimal width for centering // Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
@ -101,9 +92,9 @@ const DragDropGrid = <T extends DragDropItem>({
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap; const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
return ( return (
<Box <Box
ref={containerRef} ref={containerRef}
style={{ style={{
// Basic container styles // Basic container styles
width: '100%', width: '100%',
height: '100%', height: '100%',
@ -122,7 +113,7 @@ const DragDropGrid = <T extends DragDropItem>({
const startIndex = virtualRow.index * itemsPerRow; const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, items.length); const endIndex = Math.min(startIndex + itemsPerRow, items.length);
const rowItems = items.slice(startIndex, endIndex); const rowItems = items.slice(startIndex, endIndex);
return ( return (
<div <div
key={virtualRow.index} key={virtualRow.index}
@ -154,7 +145,7 @@ const DragDropGrid = <T extends DragDropItem>({
</React.Fragment> </React.Fragment>
); );
})} })}
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; import { ActionIcon, CheckboxIndicator } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
@ -44,7 +44,6 @@ const FileThumbnail = ({
selectedFiles, selectedFiles,
onToggleFile, onToggleFile,
onDeleteFile, onDeleteFile,
onViewFile,
onSetStatus, onSetStatus,
onReorderFiles, onReorderFiles,
onDownloadFile, onDownloadFile,
@ -61,8 +60,8 @@ const FileThumbnail = ({
// Resolve the actual File object for pin/unpin operations // Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => { const actualFile = useMemo(() => {
return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.name, file.size]); }, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false; const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => { const downloadSelectedFile = useCallback(() => {
@ -93,40 +92,6 @@ const FileThumbnail = ({
// ---- Selection ---- // ---- Selection ----
const isSelected = selectedFiles.includes(file.id); const isSelected = selectedFiles.includes(file.id);
// ---- Meta formatting ----
const prettySize = useMemo(() => {
const bytes = file.size ?? 0;
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}, [file.size]);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? '');
return (m?.[1] || '').toUpperCase();
}, [file.name]);
const pageLabel = useMemo(
() =>
file.pageCount > 0
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
: '',
[file.pageCount]
);
const dateLabel = useMemo(() => {
const d =
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
if (Number.isNaN(d.getTime())) return '';
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
year: 'numeric',
}).format(d);
}, [file.modifiedAt]);
// ---- Drag & drop wiring ---- // ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => { const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return; if (!element) return;

View File

@ -1,13 +1,7 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
Button, Text, Center, Box, import { useFileState, useFileActions } from "../../contexts/FileContext";
Notification, TextInput, LoadingOverlay, Modal, Alert, import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
Stack, Group, Portal
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { pdfExportService } from "../../services/pdfExportService"; import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService"; import { documentManipulationService } from "../../services/documentManipulationService";
// Thumbnail generation is now handled by individual PageThumbnail components // Thumbnail generation is now handled by individual PageThumbnail components
@ -19,16 +13,11 @@ import NavigationWarningModal from '../shared/NavigationWarningModal';
import { FileId } from "../../types/file"; import { FileId } from "../../types/file";
import { import {
DOMCommand,
RotatePageCommand,
DeletePagesCommand, DeletePagesCommand,
ReorderPagesCommand, ReorderPagesCommand,
SplitCommand, SplitCommand,
BulkRotateCommand, BulkRotateCommand,
BulkSplitCommand,
SplitAllCommand,
PageBreakCommand, PageBreakCommand,
BulkPageBreakCommand,
UndoManager UndoManager
} from './commands/pageCommands'; } from './commands/pageCommands';
import { GRID_CONSTANTS } from './constants'; import { GRID_CONSTANTS } from './constants';
@ -49,35 +38,24 @@ const PageEditor = ({
// Prefer IDs + selectors to avoid array identity churn // Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids; const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
const selectedFiles = selectors.getSelectedFiles();
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state // UI state
const globalProcessing = state.ui.isProcessing; const globalProcessing = state.ui.isProcessing;
const processingProgress = state.ui.processingProgress;
const hasUnsavedChanges = state.ui.hasUnsavedChanges;
// Edit state management // Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null); const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false);
const [foundDraft, setFoundDraft] = useState<any>(null);
const autoSaveTimer = useRef<number | null>(null);
// DOM-first undo manager (replaces the old React state undo system) // DOM-first undo manager (replaces the old React state undo system)
const undoManagerRef = useRef(new UndoManager()); const undoManagerRef = useRef(new UndoManager());
// Document state management // Document state management
const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument(); const { document: mergedPdfDocument } = usePageDocument();
// UI state management // UI state management
const { const {
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading, selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
setSelectionMode, setSelectedPageIds, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading, setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
togglePage, toggleSelectAll, animateReorder togglePage, toggleSelectAll, animateReorder
} = usePageEditorState(); } = usePageEditorState();
@ -146,12 +124,6 @@ const PageEditor = ({
}).filter(id => id !== ''); }).filter(id => id !== '');
}, [displayDocument]); }, [displayDocument]);
// Convert selectedPageIds to numbers for components that still need numbers
const selectedPageNumbers = useMemo(() =>
getPageNumbersFromIds(selectedPageIds),
[selectedPageIds, getPageNumbersFromIds]
);
// Select all pages by default when document initially loads // Select all pages by default when document initially loads
const hasInitializedSelection = useRef(false); const hasInitializedSelection = useRef(false);
useEffect(() => { useEffect(() => {

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
Tooltip, Tooltip,
ActionIcon, ActionIcon,
@ -9,9 +8,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut";
import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight"; import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak"; import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
import DownloadIcon from "@mui/icons-material/Download";
interface PageEditorControlsProps { interface PageEditorControlsProps {
// Close/Reset functions // Close/Reset functions
@ -46,7 +43,6 @@ interface PageEditorControlsProps {
} }
const PageEditorControls = ({ const PageEditorControls = ({
onClosePdf,
onUndo, onUndo,
onRedo, onRedo,
canUndo, canUndo,
@ -54,12 +50,7 @@ const PageEditorControls = ({
onRotate, onRotate,
onDelete, onDelete,
onSplit, onSplit,
onSplitAll,
onPageBreak, onPageBreak,
onPageBreakAll,
onExportAll,
exportLoading,
selectionMode,
selectedPageIds, selectedPageIds,
displayDocument, displayDocument,
splitPositions, splitPositions,

View File

@ -52,16 +52,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pageRefs, pageRefs,
onReorderPages, onReorderPages,
onTogglePage, onTogglePage,
onAnimateReorder,
onExecuteCommand, onExecuteCommand,
onSetStatus, onSetStatus,
onSetMovingPage, onSetMovingPage,
onDeletePage, onDeletePage,
createRotateCommand, createRotateCommand,
createDeleteCommand,
createSplitCommand, createSplitCommand,
pdfDocument, pdfDocument,
setPdfDocument,
splitPositions, splitPositions,
onInsertFiles, onInsertFiles,
}: PageThumbnailProps) => { }: PageThumbnailProps) => {
@ -172,7 +169,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
type: 'page', type: 'page',
pageNumber: page.pageNumber pageNumber: page.pageNumber
}), }),
onDrop: ({ source }) => {} onDrop: (_) => {}
}); });
(element as any).__dragCleanup = () => { (element as any).__dragCleanup = () => {

View File

@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook {
const globalProcessing = state.ui.isProcessing; const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes // Get primary file record outside useMemo to track processedFile changes
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
const processedFilePages = primaryFileRecord?.processedFile?.pages; const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops) // Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => { const mergedPdfDocument = useMemo((): PDFDocument | null => {
@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook {
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading // If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryFileRecord) { if (!primaryStirlingFileStub) {
console.log('🎬 PageEditor: No primary file record found, showing loading'); console.log('🎬 PageEditor: No primary file record found, showing loading');
return null; return null;
} }
const name = const name =
activeFileIds.length === 1 activeFileIds.length === 1
? (primaryFileRecord.name ?? 'document.pdf') ? (primaryStirlingFileStub.name ?? 'document.pdf')
: activeFileIds : activeFileIds
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) .map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + '); .join(' + ');
// Build page insertion map from files with insertion positions // Build page insertion map from files with insertion positions
@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook {
const originalFileIds: FileId[] = []; const originalFileIds: FileId[] = [];
activeFileIds.forEach(fileId => { activeFileIds.forEach(fileId => {
const record = selectors.getFileRecord(fileId); const record = selectors.getStirlingFileStub(fileId);
if (record?.insertAfterPageId !== undefined) { if (record?.insertAfterPageId !== undefined) {
if (!insertionMap.has(record.insertAfterPageId)) { if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []); insertionMap.set(record.insertAfterPageId, []);
@ -68,16 +68,15 @@ export function usePageDocument(): PageDocumentHook {
// Build pages by interleaving original pages with insertions // Build pages by interleaving original pages with insertions
let pages: PDFPage[] = []; let pages: PDFPage[] = [];
let totalPageCount = 0;
// Helper function to create pages from a file // Helper function to create pages from a file
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => { const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
const fileRecord = selectors.getFileRecord(fileId); const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!fileRecord) { if (!stirlingFileStub) {
return []; return [];
} }
const processedFile = fileRecord.processedFile; const processedFile = stirlingFileStub.processedFile;
let filePages: PDFPage[] = []; let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) { if (processedFile?.pages && processedFile.pages.length > 0) {
@ -144,8 +143,6 @@ export function usePageDocument(): PageDocumentHook {
}); });
} }
totalPageCount = pages.length;
if (pages.length === 0) { if (pages.length === 0) {
return null; return null;
} }
@ -159,7 +156,7 @@ export function usePageDocument(): PageDocumentHook {
}; };
return mergedDoc; return mergedDoc;
}, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]); }, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
// Large document detection for smart loading // Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => { const isVeryLargeDocument = useMemo(() => {

View File

@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import { FileRecord } from "../../types/fileContext"; import { StirlingFileStub } from "../../types/fileContext";
import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps { interface FileCardProps {
file: File; file: File;
record?: FileRecord; record?: StirlingFileStub;
onRemove: () => void; onRemove: () => void;
onDoubleClick?: () => void; onDoubleClick?: () => void;
onView?: () => void; onView?: () => void;
@ -25,7 +25,7 @@ interface FileCardProps {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup // Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null; const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata); const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb; const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);

View File

@ -1,18 +1,18 @@
import React, { useState } from "react"; import { useState } from "react";
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core"; import { Box, Flex, Group, Text, Button, TextInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort"; import SortIcon from "@mui/icons-material/Sort";
import FileCard from "./FileCard"; import FileCard from "./FileCard";
import { FileRecord } from "../../types/fileContext"; import { StirlingFileStub } from "../../types/fileContext";
import { FileId } from "../../types/file"; import { FileId } from "../../types/file";
interface FileGridProps { interface FileGridProps {
files: Array<{ file: File; record?: FileRecord }>; files: Array<{ file: File; record?: StirlingFileStub }>;
onRemove?: (index: number) => void; onRemove?: (index: number) => void;
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void; onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void;
onView?: (item: { file: File; record?: FileRecord }) => void; onView?: (item: { file: File; record?: StirlingFileStub }) => void;
onEdit?: (item: { file: File; record?: FileRecord }) => void; onEdit?: (item: { file: File; record?: StirlingFileStub }) => void;
onSelect?: (fileId: FileId) => void; onSelect?: (fileId: FileId) => void;
selectedFiles?: FileId[]; selectedFiles?: FileId[];
showSearch?: boolean; showSearch?: boolean;
@ -123,9 +123,17 @@ const FileGrid = ({
h="30rem" h="30rem"
style={{ overflowY: "auto", width: "100%" }} style={{ overflowY: "auto", width: "100%" }}
> >
{displayFiles.map((item, idx) => { {displayFiles
const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */; .filter(item => {
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId); if (!item.record?.id) {
console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name);
return false;
}
return true;
})
.map((item, idx) => {
const fileId = item.record!.id; // Safe to assert after filter
const originalIdx = files.findIndex(f => f.record?.id === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true; const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return ( return (
<FileCard <FileCard

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core'; import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import LocalIcon from './LocalIcon'; import LocalIcon from './LocalIcon';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@ -15,7 +15,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false); const [animationTriggered, setAnimationTriggered] = useState(false);
const [isChanging, setIsChanging] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null); const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null); const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
@ -36,7 +35,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
} }
// Start transition animation // Start transition animation
setIsChanging(true);
setPendingLanguage(value); setPendingLanguage(value);
// Simulate processing time for smooth transition // Simulate processing time for smooth transition
@ -44,7 +42,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
i18n.changeLanguage(value); i18n.changeLanguage(value);
setTimeout(() => { setTimeout(() => {
setIsChanging(false);
setPendingLanguage(null); setPendingLanguage(null);
setOpened(false); setOpened(false);
@ -54,7 +51,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
}, 200); }, 200);
}; };
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB']; supportedLanguages['en-GB'];
// Trigger animation when dropdown opens // Trigger animation when dropdown opens
@ -77,8 +74,8 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
} }
`} `}
</style> </style>
<Menu <Menu
opened={opened} opened={opened}
onChange={setOpened} onChange={setOpened}
width={600} width={600}
position={position} position={position}
@ -166,15 +163,15 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
justifyContent: 'flex-start', justifyContent: 'flex-start',
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
backgroundColor: option.value === i18n.language backgroundColor: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))' ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
: 'transparent', : 'transparent',
color: option.value === i18n.language color: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))' ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))', : 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': { '&:hover': {
backgroundColor: option.value === i18n.language backgroundColor: option.value === i18n.language
? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))' ? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))'
: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', : 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
transform: 'translateY(-1px)', transform: 'translateY(-1px)',
@ -223,4 +220,4 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal
); );
}; };
export default LanguageSelector; export default LanguageSelector;

View File

@ -13,7 +13,7 @@ try {
localIconCount = Object.keys(iconSet.icons || {}).length; localIconCount = Object.keys(iconSet.icons || {}).length;
console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`); console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`);
} }
} catch (error) { } catch {
console.info(' Local icons not available - using CDN fallback'); console.info(' Local icons not available - using CDN fallback');
} }
@ -31,10 +31,10 @@ interface LocalIconProps {
*/ */
export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => { export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
// Convert our icon naming convention to the local collection format // Convert our icon naming convention to the local collection format
const iconName = icon.startsWith('material-symbols:') const iconName = icon.startsWith('material-symbols:')
? icon ? icon
: `material-symbols:${icon}`; : `material-symbols:${icon}`;
// Development logging (only in dev mode) // Development logging (only in dev mode)
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
const logKey = `icon-${iconName}`; const logKey = `icon-${iconName}`;
@ -44,9 +44,9 @@ export const LocalIcon: React.FC<LocalIconProps> = ({ icon, ...props }) => {
sessionStorage.setItem(logKey, 'logged'); sessionStorage.setItem(logKey, 'logged');
} }
} }
// Always render the icon - Iconify will use local if available, CDN if not // Always render the icon - Iconify will use local if available, CDN if not
return <Icon icon={iconName} {...props} />; return <Icon icon={iconName} {...props} />;
}; };
export default LocalIcon; export default LocalIcon;

View File

@ -3,7 +3,6 @@ import { ActionIcon, Stack, Divider } from "@mantine/core";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LocalIcon from './LocalIcon'; import LocalIcon from './LocalIcon';
import { useRainbowThemeContext } from "./RainbowThemeProvider"; import { useRainbowThemeContext } from "./RainbowThemeProvider";
import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useIsOverflowing } from '../../hooks/useIsOverflowing';
import { useFilesModalContext } from '../../contexts/FilesModalContext'; import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
@ -19,8 +18,7 @@ import {
getActiveNavButton, getActiveNavButton,
} from './quickAccessBar/QuickAccessBar'; } from './quickAccessBar/QuickAccessBar';
const QuickAccessBar = forwardRef<HTMLDivElement>(({ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext();

View File

@ -29,12 +29,11 @@ export default function RightRail() {
// File state and selection // File state and selection
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection(); const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
const { removeFiles } = useFileManagement(); const { removeFiles } = useFileManagement();
const activeFiles = selectors.getFiles(); const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature(); const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords();
// Compute selection state and total items // Compute selection state and total items
const getSelectionState = useCallback(() => { const getSelectionState = useCallback(() => {
@ -85,7 +84,7 @@ export default function RightRail() {
if (currentView === 'fileEditor' || currentView === 'viewer') { if (currentView === 'fileEditor' || currentView === 'viewer') {
// Download selected files (or all if none selected) // Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => { filesToDownload.forEach(file => {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = URL.createObjectURL(file); link.href = URL.createObjectURL(file);
@ -206,8 +205,8 @@ export default function RightRail() {
)} )}
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
<div <div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`} className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView === 'viewer'} aria-hidden={currentView === 'viewer'}
> >
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
@ -358,14 +357,14 @@ export default function RightRail() {
<LanguageSelector position="left-start" offset={6} compact /> <LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={ <Tooltip content={
currentView === 'pageEditor' currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF') ? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) : (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow> } position="left" offset={12} arrow>
<div> <div>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={handleExportAll} onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0} disabled={currentView === 'viewer' || totalItems === 0}

View File

@ -82,8 +82,8 @@ export function adjustFontSizeToFit(
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
try { ro.disconnect(); } catch {} try { ro.disconnect(); } catch { /* Ignore errors */ }
try { mo.disconnect(); } catch {} try { mo.disconnect(); } catch { /* Ignore errors */ }
}; };
} }

View File

@ -1,10 +1,10 @@
/** /**
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar * ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
* *
* When a user selects a tool from the All Tools list, this component displays the tool's * When a user selects a tool from the All Tools list, this component displays the tool's
* icon and name at the top of the navigation bar. It provides a quick way to see which * icon and name at the top of the navigation bar. It provides a quick way to see which
* tool is currently active and offers a back button to return to the All Tools list. * tool is currently active and offers a back button to return to the All Tools list.
* *
* Features: * Features:
* - Shows tool icon and name when a tool is selected * - Shows tool icon and name when a tool is selected
* - Hover to reveal back arrow for returning to All Tools * - Hover to reveal back arrow for returning to All Tools
@ -28,7 +28,7 @@ interface ActiveToolButtonProps {
const NAV_IDS = ['read', 'sign', 'automate']; const NAV_IDS = ['read', 'sign', 'automate'];
const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setActiveButton }) => { const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton }) => {
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow();
const { getHomeNavigation } = useSidebarNavigation(); const { getHomeNavigation } = useSidebarNavigation();
@ -41,7 +41,6 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null); const [indicatorTool, setIndicatorTool] = useState<typeof selectedTool | null>(null);
const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false); const [indicatorVisible, setIndicatorVisible] = useState<boolean>(false);
const [replayAnim, setReplayAnim] = useState<boolean>(false); const [replayAnim, setReplayAnim] = useState<boolean>(false);
const [isAnimating, setIsAnimating] = useState<boolean>(false);
const [isBackHover, setIsBackHover] = useState<boolean>(false); const [isBackHover, setIsBackHover] = useState<boolean>(false);
const prevKeyRef = useRef<string | null>(null); const prevKeyRef = useRef<string | null>(null);
const collapseTimeoutRef = useRef<number | null>(null); const collapseTimeoutRef = useRef<number | null>(null);
@ -74,11 +73,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
replayRafRef.current = requestAnimationFrame(() => { replayRafRef.current = requestAnimationFrame(() => {
setReplayAnim(true); setReplayAnim(true);
}); });
setIsAnimating(true);
prevKeyRef.current = (selectedToolKey as string) || null; prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => { animTimeoutRef.current = window.setTimeout(() => {
setReplayAnim(false); setReplayAnim(false);
setIsAnimating(false);
animTimeoutRef.current = null; animTimeoutRef.current = null;
}, 500); }, 500);
} }
@ -87,10 +84,8 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
clearTimers(); clearTimers();
setIndicatorTool(selectedTool); setIndicatorTool(selectedTool);
setIndicatorVisible(true); setIndicatorVisible(true);
setIsAnimating(true);
prevKeyRef.current = (selectedToolKey as string) || null; prevKeyRef.current = (selectedToolKey as string) || null;
animTimeoutRef.current = window.setTimeout(() => { animTimeoutRef.current = window.setTimeout(() => {
setIsAnimating(false);
animTimeoutRef.current = null; animTimeoutRef.current = null;
}, 500); }, 500);
} }
@ -98,11 +93,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ activeButton, setAc
const triggerCollapse = () => { const triggerCollapse = () => {
clearTimers(); clearTimers();
setIndicatorVisible(false); setIndicatorVisible(false);
setIsAnimating(true);
collapseTimeoutRef.current = window.setTimeout(() => { collapseTimeoutRef.current = window.setTimeout(() => {
setIndicatorTool(null); setIndicatorTool(null);
prevKeyRef.current = null; prevKeyRef.current = null;
setIsAnimating(false);
collapseTimeoutRef.current = null; collapseTimeoutRef.current = null;
}, 500); // match CSS transition duration }, 500); // match CSS transition duration
} }

View File

@ -1,5 +1,5 @@
import React, { useMemo } from 'react'; import React from 'react';
import { Box, Stack, Text } from '@mantine/core'; import { Box, Stack } from '@mantine/core';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy'; import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import ToolButton from './toolPicker/ToolButton'; import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -40,12 +40,10 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
</Stack> </Stack>
</Box> </Box>
))} ))}
{/* global spacer to allow scrolling past last row in search mode */} {/* Global spacer to allow scrolling past last row in search mode */}
<div aria-hidden style={{ height: 200 }} /> <div aria-hidden style={{ height: 200 }} />
</Stack> </Stack>
); );
}; };
export default SearchResults; export default SearchResults;

View File

@ -1,5 +1,3 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker'; import ToolPicker from './ToolPicker';
@ -8,12 +6,11 @@ import ToolRenderer from './ToolRenderer';
import ToolSearch from './toolPicker/ToolSearch'; import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext"; import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css'; import rainbowStyles from '../../styles/rainbow.module.css';
import { Stack, ScrollArea } from '@mantine/core'; import { ScrollArea } from '@mantine/core';
// No props needed - component uses context // No props needed - component uses context
export default function ToolPanel() { export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext(); const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs; const { toolPanelRef } = sidebarRefs;
@ -27,7 +24,6 @@ export default function ToolPanel() {
filteredTools, filteredTools,
toolRegistry, toolRegistry,
setSearchQuery, setSearchQuery,
handleBackToTools
} = useToolWorkflow(); } = useToolWorkflow();
const { selectedToolKey, handleToolSelect } = useToolWorkflow(); const { selectedToolKey, handleToolSelect } = useToolWorkflow();

View File

@ -3,7 +3,6 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import AddPasswordSettings from './AddPasswordSettings'; import AddPasswordSettings from './AddPasswordSettings';
import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters'; import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
import type { AddPasswordParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters';
// Mock useTranslation with predictable return values // Mock useTranslation with predictable return values
const mockT = vi.fn((key: string) => `mock-${key}`); const mockT = vi.fn((key: string) => `mock-${key}`);

View File

@ -1,5 +1,4 @@
import React from "react"; import { Stack, PasswordInput, Select } from "@mantine/core";
import { Stack, Text, PasswordInput, Select } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters"; import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters";

View File

@ -1,5 +1,4 @@
import React from "react"; import { Button, Stack } from "@mantine/core";
import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
interface WatermarkTypeSettingsProps { interface WatermarkTypeSettingsProps {

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Stack, Text, TextInput } from "@mantine/core"; import { Stack, TextInput } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters"; import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import { removeEmojis } from "../../../utils/textUtils"; import { removeEmojis } from "../../../utils/textUtils";

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Button, Button,
@ -38,10 +38,8 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
automationIcon, automationIcon,
setAutomationIcon, setAutomationIcon,
selectedTools, selectedTools,
addTool,
removeTool, removeTool,
updateTool, updateTool,
hasUnsavedChanges,
canSaveAutomation, canSaveAutomation,
getToolName, getToolName,
getToolDefaultParameters getToolDefaultParameters
@ -84,14 +82,6 @@ export default function AutomationCreation({ mode, existingAutomation, onBack, o
updateTool(selectedTools.length, newTool); updateTool(selectedTools.length, newTool);
}; };
const handleBackClick = () => {
if (hasUnsavedChanges()) {
setUnsavedWarningOpen(true);
} else {
onBack();
}
};
const handleConfirmBack = () => { const handleConfirmBack = () => {
setUnsavedWarningOpen(false); setUnsavedWarningOpen(false);
onBack(); onBack();

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core'; import { Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert'; import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
@ -69,11 +69,11 @@ export default function AutomationEntry({
const toolChain = operations.map((op, index) => ( const toolChain = operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}> <React.Fragment key={`${op}-${index}`}>
<Text <Text
component="span" component="span"
size="sm" size="sm"
fw={600} fw={600}
style={{ style={{
color: 'var(--mantine-primary-color-filled)', color: 'var(--mantine-primary-color-filled)',
background: 'var(--mantine-primary-color-light)', background: 'var(--mantine-primary-color-light)',
padding: '2px 6px', padding: '2px 6px',
@ -241,12 +241,12 @@ export default function AutomationEntry({
// Show tooltip if there's a description OR operations to display // Show tooltip if there's a description OR operations to display
const shouldShowTooltip = description || operations.length > 0; const shouldShowTooltip = description || operations.length > 0;
return shouldShowTooltip ? ( return shouldShowTooltip ? (
<Tooltip <Tooltip
content={createTooltipContent()} content={createTooltipContent()}
position="right" position="right"
arrow={true} arrow={true}
delay={500} delay={500}
> >
{boxContent} {boxContent}

View File

@ -20,11 +20,11 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
const { selectedFiles } = useFileSelection(); const { selectedFiles } = useFileSelection();
const toolRegistry = useFlatToolRegistry(); const toolRegistry = useFlatToolRegistry();
const cleanup = useResourceCleanup(); const cleanup = useResourceCleanup();
// Progress tracking state // Progress tracking state
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]); const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
const [currentStepIndex, setCurrentStepIndex] = useState(-1); const [currentStepIndex, setCurrentStepIndex] = useState(-1);
// Use the operation hook's loading state // Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false; const isExecuting = automateOperation?.isLoading || false;
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null; const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
@ -74,15 +74,15 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
try { try {
// Use the automateOperation.executeOperation to handle file consumption properly // Use the automateOperation.executeOperation to handle file consumption properly
await automateOperation.executeOperation( await automateOperation.executeOperation(
{ {
automationConfig: automation, automationConfig: automation,
onStepStart: (stepIndex: number, operationName: string) => { onStepStart: (stepIndex: number, _operationName: string) => {
setCurrentStepIndex(stepIndex); setCurrentStepIndex(stepIndex);
setExecutionSteps(prev => prev.map((step, idx) => setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
)); ));
}, },
onStepComplete: (stepIndex: number, resultFiles: File[]) => { onStepComplete: (stepIndex: number, _resultFiles: File[]) => {
setExecutionSteps(prev => prev.map((step, idx) => setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
)); ));
@ -95,7 +95,7 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
}, },
selectedFiles selectedFiles
); );
// Mark all as completed and reset current step // Mark all as completed and reset current step
setCurrentStepIndex(-1); setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`); console.log(`✅ Automation completed successfully`);
@ -118,20 +118,20 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
case EXECUTION_STATUS.ERROR: case EXECUTION_STATUS.ERROR:
return <span style={{ fontSize: 16, color: 'red' }}></span>; return <span style={{ fontSize: 16, color: 'red' }}></span>;
case EXECUTION_STATUS.RUNNING: case EXECUTION_STATUS.RUNNING:
return <div style={{ return <div style={{
width: 16, width: 16,
height: 16, height: 16,
border: '2px solid #ccc', border: '2px solid #ccc',
borderTop: '2px solid #007bff', borderTop: '2px solid #007bff',
borderRadius: '50%', borderRadius: '50%',
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite` animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
}} />; }} />;
default: default:
return <div style={{ return <div style={{
width: 16, width: 16,
height: 16, height: 16,
border: '2px solid #ccc', border: '2px solid #ccc',
borderRadius: '50%' borderRadius: '50%'
}} />; }} />;
} }
}; };
@ -170,8 +170,8 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
{getStepIcon(step)} {getStepIcon(step)}
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text <Text
size="sm" size="sm"
style={{ style={{
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)', color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400 fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
@ -220,4 +220,4 @@ export default function AutomationRun({ automation, onComplete, automateOperatio
</style> </style>
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Modal, Modal,
@ -32,7 +32,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
const { t } = useTranslation(); const { t } = useTranslation();
const [parameters, setParameters] = useState<any>({}); const [parameters, setParameters] = useState<any>({});
const [isValid, setIsValid] = useState(true);
// Get tool info from registry // Get tool info from registry
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry]; const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
@ -87,9 +86,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
}; };
const handleSave = () => { const handleSave = () => {
if (isValid) { onSave(parameters);
onSave(parameters);
}
}; };
return ( return (
@ -127,7 +124,6 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
<Button <Button
leftSection={<CheckIcon />} leftSection={<CheckIcon />}
onClick={handleSave} onClick={handleSave}
disabled={!isValid}
> >
{t('automate.config.save', 'Save Configuration')} {t('automate.config.save', 'Save Configuration')}
</Button> </Button>

View File

@ -1,4 +1,4 @@
import { Stack, Text, Checkbox } from "@mantine/core"; import { Stack, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters"; import { ChangePermissionsParameters } from "../../../hooks/tools/changePermissions/useChangePermissionsParameters";

View File

@ -22,13 +22,13 @@ import {
OUTPUT_OPTIONS, OUTPUT_OPTIONS,
FIT_OPTIONS FIT_OPTIONS
} from "../../../constants/convertConstants"; } from "../../../constants/convertConstants";
import { FileId } from "../../../types/file"; import { StirlingFile } from "../../../types/fileContext";
interface ConvertSettingsProps { interface ConvertSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: (key: keyof ConvertParameters, value: any) => void;
getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>; getAvailableToExtensions: (fromExtension: string) => Array<{value: string, label: string, group: string}>;
selectedFiles: File[]; selectedFiles: StirlingFile[];
disabled?: boolean; disabled?: boolean;
} }
@ -129,7 +129,7 @@ const ConvertSettings = ({
}; };
const filterFilesByExtension = (extension: string) => { const filterFilesByExtension = (extension: string) => {
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[]; const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as StirlingFile[];
return files.filter(file => { return files.filter(file => {
const fileExtension = detectFileExtension(file.name); const fileExtension = detectFileExtension(file.name);
@ -143,21 +143,8 @@ const ConvertSettings = ({
}); });
}; };
const updateFileSelection = (files: File[]) => { const updateFileSelection = (files: StirlingFile[]) => {
// Map File objects to their actual IDs in FileContext const fileIds = files.map(file => file.fileId);
const fileIds = files.map(file => {
// Find the file ID by matching file properties
const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id))
.find(record =>
record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified
);
return fileRecord?.id;
}).filter((id): id is FileId => id !== undefined); // Type guard to ensure only strings
setSelectedFiles(fileIds); setSelectedFiles(fileIds);
}; };

View File

@ -3,11 +3,12 @@ import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters'; import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection'; import { usePdfSignatureDetection } from '../../../hooks/usePdfSignatureDetection';
import { StirlingFile } from '../../../types/fileContext';
interface ConvertToPdfaSettingsProps { interface ConvertToPdfaSettingsProps {
parameters: ConvertParameters; parameters: ConvertParameters;
onParameterChange: (key: keyof ConvertParameters, value: any) => void; onParameterChange: (key: keyof ConvertParameters, value: any) => void;
selectedFiles: File[]; selectedFiles: StirlingFile[];
disabled?: boolean; disabled?: boolean;
} }

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Stack, Select, Text, Divider } from '@mantine/core'; import { Stack, Select, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import LanguagePicker from './LanguagePicker'; import LanguagePicker from './LanguagePicker';
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters'; import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';

View File

@ -8,11 +8,7 @@ interface RemoveCertificateSignSettingsProps {
disabled?: boolean; disabled?: boolean;
} }
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({ const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = (_) => {
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -24,4 +20,4 @@ const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps
); );
}; };
export default RemoveCertificateSignSettings; export default RemoveCertificateSignSettings;

View File

@ -1,4 +1,4 @@
import { Stack, Text, PasswordInput } from "@mantine/core"; import { Stack, PasswordInput } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters"; import { RemovePasswordParameters } from "../../../hooks/tools/removePassword/useRemovePasswordParameters";

View File

@ -8,11 +8,7 @@ interface RepairSettingsProps {
disabled?: boolean; disabled?: boolean;
} }
const RepairSettings: React.FC<RepairSettingsProps> = ({ const RepairSettings: React.FC<RepairSettingsProps> = (_) => {
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -24,4 +20,4 @@ const RepairSettings: React.FC<RepairSettingsProps> = ({
); );
}; };
export default RepairSettings; export default RepairSettings;

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Text, Anchor } from "@mantine/core"; import { Text, Anchor } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
@ -6,9 +6,10 @@ import UploadIcon from '@mui/icons-material/Upload';
import { useFilesModalContext } from "../../../contexts/FilesModalContext"; import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext"; import { useAllFiles } from "../../../contexts/FileContext";
import { useFileManager } from "../../../hooks/useFileManager"; import { useFileManager } from "../../../hooks/useFileManager";
import { StirlingFile } from "../../../types/fileContext";
export interface FileStatusIndicatorProps { export interface FileStatusIndicatorProps {
selectedFiles?: File[]; selectedFiles?: StirlingFile[];
placeholder?: string; placeholder?: string;
} }
@ -17,7 +18,7 @@ const FileStatusIndicator = ({
}: FileStatusIndicatorProps) => { }: FileStatusIndicatorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { openFilesModal, onFilesSelect } = useFilesModalContext(); const { openFilesModal, onFilesSelect } = useFilesModalContext();
const { files: workbenchFiles } = useAllFiles(); const { files: stirlingFileStubs } = useAllFiles();
const { loadRecentFiles } = useFileManager(); const { loadRecentFiles } = useFileManager();
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null); const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
@ -27,7 +28,7 @@ const FileStatusIndicator = ({
try { try {
const recentFiles = await loadRecentFiles(); const recentFiles = await loadRecentFiles();
setHasRecentFiles(recentFiles.length > 0); setHasRecentFiles(recentFiles.length > 0);
} catch (error) { } catch {
setHasRecentFiles(false); setHasRecentFiles(false);
} }
}; };
@ -55,7 +56,7 @@ const FileStatusIndicator = ({
} }
// Check if there are no files in the workbench // Check if there are no files in the workbench
if (workbenchFiles.length === 0) { if (stirlingFileStubs.length === 0) {
// If no recent files, show upload button // If no recent files, show upload button
if (!hasRecentFiles) { if (!hasRecentFiles) {
return ( return (

View File

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FileStatusIndicator from './FileStatusIndicator'; import FileStatusIndicator from './FileStatusIndicator';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesToolStepProps { export interface FilesToolStepProps {
selectedFiles: File[]; selectedFiles: StirlingFile[];
isCollapsed?: boolean; isCollapsed?: boolean;
onCollapsedClick?: () => void; onCollapsedClick?: () => void;
placeholder?: string; placeholder?: string;

View File

@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef } from "react";
import { Button, Group, Stack } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import UndoIcon from "@mui/icons-material/Undo"; import UndoIcon from "@mui/icons-material/Undo";

View File

@ -3,8 +3,6 @@ import { Stack, Text, Divider, Card, Group, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools'; import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
export interface SuggestedToolsSectionProps {}
export function SuggestedToolsSection(): React.ReactElement { export function SuggestedToolsSection(): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const suggestedTools = useSuggestedTools(); const suggestedTools = useSuggestedTools();

View File

@ -1,5 +1,5 @@
import React, { createContext, useContext, useMemo, useRef } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import { Text, Stack, Box, Flex, Divider } from '@mantine/core'; import { Text, Stack, Flex, Divider } from '@mantine/core';
import LocalIcon from '../../shared/LocalIcon'; import LocalIcon from '../../shared/LocalIcon';
import { Tooltip } from '../../shared/Tooltip'; import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../../types/tips'; import { TooltipTip } from '../../../types/tips';

View File

@ -4,9 +4,10 @@ import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton'; import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle'; import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
import { StirlingFile } from '../../../types/fileContext';
export interface FilesStepConfig { export interface FilesStepConfig {
selectedFiles: File[]; selectedFiles: StirlingFile[];
isCollapsed?: boolean; isCollapsed?: boolean;
placeholder?: string; placeholder?: string;
onCollapsedClick?: () => void; onCollapsedClick?: () => void;
@ -80,7 +81,7 @@ export function createToolFlow(config: ToolFlowConfig) {
})} })}
{/* Middle Steps */} {/* Middle Steps */}
{config.steps.map((stepConfig, index) => {config.steps.map((stepConfig) =>
steps.create(stepConfig.title, { steps.create(stepConfig.title, {
isVisible: stepConfig.isVisible, isVisible: stepConfig.isVisible,
isCollapsed: stepConfig.isCollapsed, isCollapsed: stepConfig.isCollapsed,

View File

@ -1,5 +1,4 @@
import React from 'react'; import { Box } from '@mantine/core';
import { Box, Stack } from '@mantine/core';
import ToolButton from '../toolPicker/ToolButton'; import ToolButton from '../toolPicker/ToolButton';
import SubcategoryHeader from './SubcategoryHeader'; import SubcategoryHeader from './SubcategoryHeader';

View File

@ -8,11 +8,7 @@ interface SingleLargePageSettingsProps {
disabled?: boolean; disabled?: boolean;
} }
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({ const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = (_) => {
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -24,4 +20,4 @@ const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
); );
}; };
export default SingleLargePageSettings; export default SingleLargePageSettings;

View File

@ -126,7 +126,7 @@ const ToolSearch = ({
key={id} key={id}
variant="subtle" variant="subtle"
onClick={() => { onClick={() => {
onToolSelect && onToolSelect(id); onToolSelect?.(id);
setDropdownOpen(false); setDropdownOpen(false);
}} }}
leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>} leftSection={<div style={{ color: "var(--tools-text-and-icon-color)" }}>{tool.icon}</div>}

View File

@ -8,11 +8,7 @@ interface UnlockPdfFormsSettingsProps {
disabled?: boolean; disabled?: boolean;
} }
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({ const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = (_) => {
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -24,4 +20,4 @@ const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
); );
}; };
export default UnlockPdfFormsSettings; export default UnlockPdfFormsSettings;

View File

@ -1,20 +1,19 @@
import React, { useEffect, useState, useRef, useCallback } from "react"; import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core"; import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { pdfWorkerManager } from "../../services/pdfWorkerManager"; import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage"; import FirstPageIcon from "@mui/icons-material/FirstPage";
import LastPageIcon from "@mui/icons-material/LastPage"; import LastPageIcon from "@mui/icons-material/LastPage";
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book) import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import CloseIcon from "@mui/icons-material/Close"; import CloseIcon from "@mui/icons-material/Close";
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage"; import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader'; import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext"; import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl"; import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file"; import { FileId } from "../../types/file";
@ -141,8 +140,6 @@ export interface ViewerProps {
} }
const Viewer = ({ const Viewer = ({
sidebarsVisible,
setSidebarsVisible,
onClose, onClose,
previewFile, previewFile,
}: ViewerProps) => { }: ViewerProps) => {
@ -151,13 +148,7 @@ const Viewer = ({
// Get current file from FileContext // Get current file from FileContext
const { selectors } = useFileState(); const { selectors } = useFileState();
const { actions } = useFileActions();
const currentFile = useCurrentFile();
const getCurrentFile = () => currentFile.file;
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
const clearAllFiles = actions.clearAllFiles;
const addFiles = actions.addFiles;
const activeFiles = selectors.getFiles(); const activeFiles = selectors.getFiles();
// Tab management for multiple files // Tab management for multiple files
@ -201,7 +192,7 @@ const Viewer = ({
const effectiveFile = React.useMemo(() => { const effectiveFile = React.useMemo(() => {
if (previewFile) { if (previewFile) {
// Validate the preview file // Validate the preview file
if (!(previewFile instanceof File)) { if (!isFileObject(previewFile)) {
return null; return null;
} }
@ -405,7 +396,7 @@ const Viewer = ({
// Start progressive preloading after a short delay // Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100); setTimeout(() => startProgressivePreload(), 100);
} }
} catch (error) { } catch {
if (!cancelled) { if (!cancelled) {
setPageImages([]); setPageImages([]);
setNumPages(0); setNumPages(0);

View File

@ -19,7 +19,10 @@ import {
FileContextStateValue, FileContextStateValue,
FileContextActionsValue, FileContextActionsValue,
FileContextActions, FileContextActions,
FileRecord FileId,
StirlingFileStub,
StirlingFile,
createStirlingFile
} from '../types/fileContext'; } from '../types/fileContext';
// Import modular components // Import modular components
@ -29,7 +32,6 @@ import { AddedFile, addFiles, consumeFiles, undoConsumeFiles, createFileActions
import { FileLifecycleManager } from './file/lifecycle'; import { FileLifecycleManager } from './file/lifecycle';
import { FileStateContext, FileActionsContext } from './file/contexts'; import { FileStateContext, FileActionsContext } from './file/contexts';
import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext'; import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
import { FileId } from '../types/file';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -37,7 +39,6 @@ const DEBUG = process.env.NODE_ENV === 'development';
// Inner provider component that has access to IndexedDB // Inner provider component that has access to IndexedDB
function FileContextInner({ function FileContextInner({
children, children,
enableUrlSync = true,
enablePersistence = true enablePersistence = true
}: FileContextProviderProps) { }: FileContextProviderProps) {
const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState); const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
@ -79,7 +80,7 @@ function FileContextInner({
} }
// File operations using unified addFiles helper with persistence // File operations using unified addFiles helper with persistence
const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<File[]> => { const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise<StirlingFile[]> => {
const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested // Auto-select the newly added files if requested
@ -98,15 +99,15 @@ function FileContextInner({
})); }));
} }
return addedFilesWithIds.map(({ file }) => file); return addedFilesWithIds.map(({ file, id }) => createStirlingFile(file, id));
}, [indexedDB, enablePersistence]); }, [indexedDB, enablePersistence]);
const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<File[]> => { const addProcessedFiles = useCallback(async (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>): Promise<StirlingFile[]> => {
const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager); const result = await addFiles('processed', { filesWithThumbnails }, stateRef, filesRef, dispatch, lifecycleManager);
return result.map(({ file }) => file); return result.map(({ file, id }) => createStirlingFile(file, id));
}, []); }, []);
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<File[]> => { const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: any }>, options?: { selectFiles?: boolean }): Promise<StirlingFile[]> => {
const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager); const result = await addFiles('stored', { filesWithMetadata }, stateRef, filesRef, dispatch, lifecycleManager);
// Auto-select the newly added files if requested // Auto-select the newly added files if requested
@ -114,7 +115,7 @@ function FileContextInner({
selectFiles(result); selectFiles(result);
} }
return result.map(({ file }) => file); return result.map(({ file, id }) => createStirlingFile(file, id));
}, []); }, []);
// Action creators // Action creators
@ -122,42 +123,21 @@ function FileContextInner({
// Helper functions for pinned files // Helper functions for pinned files
const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => { const consumeFilesWrapper = useCallback(async (inputFileIds: FileId[], outputFiles: File[]): Promise<FileId[]> => {
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch, indexedDB); return consumeFiles(inputFileIds, outputFiles, filesRef, dispatch, indexedDB);
}, [indexedDB]); }, [indexedDB]);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputFileRecords: FileRecord[], outputFileIds: FileId[]): Promise<void> => { const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds, stateRef, filesRef, dispatch, indexedDB); return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
}, [indexedDB]); }, [indexedDB]);
// Helper to find FileId from File object // File pinning functions - use StirlingFile directly
const findFileId = useCallback((file: File): FileId | undefined => { const pinFileWrapper = useCallback((file: StirlingFile) => {
return (Object.keys(stateRef.current.files.byId) as FileId[]).find(id => { baseActions.pinFile(file.fileId);
const storedFile = filesRef.current.get(id); }, [baseActions]);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
}, []);
// File-to-ID wrapper functions for pinning const unpinFileWrapper = useCallback((file: StirlingFile) => {
const pinFileWrapper = useCallback((file: File) => { baseActions.unpinFile(file.fileId);
const fileId = findFileId(file); }, [baseActions]);
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 // Complete actions object
const actions = useMemo<FileContextActions>(() => ({ const actions = useMemo<FileContextActions>(() => ({
@ -178,8 +158,8 @@ function FileContextInner({
} }
} }
}, },
updateFileRecord: (fileId: FileId, updates: Partial<FileRecord>) => updateStirlingFileStub: (fileId: FileId, updates: Partial<StirlingFileStub>) =>
lifecycleManager.updateFileRecord(fileId, updates, stateRef), lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef),
reorderFiles: (orderedFileIds: FileId[]) => { reorderFiles: (orderedFileIds: FileId[]) => {
dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } }); dispatch({ type: 'REORDER_FILES', payload: { orderedFileIds } });
}, },
@ -303,7 +283,7 @@ export {
useFileSelection, useFileSelection,
useFileManagement, useFileManagement,
useFileUI, useFileUI,
useFileRecord, useStirlingFileStub,
useAllFiles, useAllFiles,
useSelectedFiles, useSelectedFiles,
// Primary API hooks for tools // Primary API hooks for tools

View File

@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { StoredFile, fileStorage } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils'; import { downloadFiles } from '../utils/downloadUtils';
import { FileId } from '../types/file'; import { FileId } from '../types/file';

View File

@ -6,7 +6,7 @@
import React, { createContext, useContext, useCallback, useRef } from 'react'; import React, { createContext, useContext, useCallback, useRef } from 'react';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
import { fileStorage, StoredFile } from '../services/fileStorage'; import { fileStorage } from '../services/fileStorage';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../utils/thumbnailUtils';
@ -61,7 +61,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
const thumbnail = existingThumbnail || await generateThumbnailForFile(file); const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Store in IndexedDB // Store in IndexedDB
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail); await fileStorage.storeFile(file, fileId, thumbnail);
// Cache the file object for immediate reuse // Cache the file object for immediate reuse
fileCache.current.set(fileId, { file, lastAccessed: Date.now() }); fileCache.current.set(fileId, { file, lastAccessed: Date.now() });

View File

@ -103,7 +103,7 @@ const NavigationActionsContext = createContext<NavigationContextActionsValue | u
export const NavigationProvider: React.FC<{ export const NavigationProvider: React.FC<{
children: React.ReactNode; children: React.ReactNode;
enableUrlSync?: boolean; enableUrlSync?: boolean;
}> = ({ children, enableUrlSync = true }) => { }> = ({ children }) => {
const [state, dispatch] = useReducer(navigationReducer, initialState); const [state, dispatch] = useReducer(navigationReducer, initialState);
const toolRegistry = useFlatToolRegistry(); const toolRegistry = useFlatToolRegistry();

View File

@ -89,6 +89,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
clearToolSelection: () => void; clearToolSelection: () => void;
// Tool Reset Actions // Tool Reset Actions
toolResetFunctions: Record<string, () => void>;
registerToolReset: (toolId: string, resetFunction: () => void) => void; registerToolReset: (toolId: string, resetFunction: () => void) => void;
resetTool: (toolId: string) => void; resetTool: (toolId: string) => void;
@ -258,6 +259,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
clearToolSelection: () => actions.setSelectedTool(null), clearToolSelection: () => actions.setSelectedTool(null),
// Tool Reset Actions // Tool Reset Actions
toolResetFunctions,
registerToolReset, registerToolReset,
resetTool, resetTool,

View File

@ -6,7 +6,7 @@ import { FileId } from '../../types/file';
import { import {
FileContextState, FileContextState,
FileContextAction, FileContextAction,
FileRecord StirlingFileStub
} from '../../types/fileContext'; } from '../../types/fileContext';
// Initial state // Initial state
@ -29,7 +29,7 @@ export const initialFileContextState: FileContextState = {
function processFileSwap( function processFileSwap(
state: FileContextState, state: FileContextState,
filesToRemove: FileId[], filesToRemove: FileId[],
filesToAdd: FileRecord[] filesToAdd: StirlingFileStub[]
): FileContextState { ): FileContextState {
// Only remove unpinned files // Only remove unpinned files
const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id)); const unpinnedRemoveIds = filesToRemove.filter(id => !state.pinnedFiles.has(id));
@ -70,11 +70,11 @@ function processFileSwap(
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState { export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) { switch (action.type) {
case 'ADD_FILES': { case 'ADD_FILES': {
const { fileRecords } = action.payload; const { stirlingFileStubs } = action.payload;
const newIds: FileId[] = []; const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId }; const newById: Record<FileId, StirlingFileStub> = { ...state.files.byId };
fileRecords.forEach(record => { stirlingFileStubs.forEach(record => {
// Only add if not already present (dedupe by stable ID) // Only add if not already present (dedupe by stable ID)
if (!newById[record.id]) { if (!newById[record.id]) {
newIds.push(record.id); newIds.push(record.id);
@ -233,13 +233,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
} }
case 'CONSUME_FILES': { case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload; const { inputFileIds, outputStirlingFileStubs } = action.payload;
return processFileSwap(state, inputFileIds, outputFileRecords); return processFileSwap(state, inputFileIds, outputStirlingFileStubs);
} }
case 'UNDO_CONSUME_FILES': { case 'UNDO_CONSUME_FILES': {
const { inputFileRecords, outputFileIds } = action.payload; const { inputStirlingFileStubs, outputFileIds } = action.payload;
return processFileSwap(state, outputFileIds, inputFileRecords); return processFileSwap(state, outputFileIds, inputStirlingFileStubs);
} }
case 'RESET_CONTEXT': { case 'RESET_CONTEXT': {

View File

@ -3,18 +3,17 @@
*/ */
import { import {
FileRecord, StirlingFileStub,
FileContextAction, FileContextAction,
FileContextState, FileContextState,
toFileRecord, toStirlingFileStub,
createFileId, createFileId,
createQuickKey createQuickKey
} from '../../types/fileContext'; } from '../../types/fileContext';
import { FileId, FileMetadata } from '../../types/file'; import { FileId, FileMetadata } from '../../types/file';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils'; import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle'; import { FileLifecycleManager } from './lifecycle';
import { fileProcessingService } from '../../services/fileProcessingService'; import { buildQuickKeySet } from './fileSelectors';
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -109,8 +108,8 @@ export async function addFiles(
await addFilesMutex.lock(); await addFilesMutex.lock();
try { try {
const fileRecords: FileRecord[] = []; const stirlingFileStubs: StirlingFileStub[] = [];
const addedFiles: AddedFile[] = []; const addedFiles: AddedFile[] = [];
// Build quickKey lookup from existing files for deduplication // Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
@ -163,7 +162,7 @@ export async function addFiles(
} }
// Create record with immediate thumbnail and page metadata // Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation) // Track blob URLs for cleanup (images return blob URLs that need revocation)
@ -184,7 +183,7 @@ export async function addFiles(
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
} }
break; break;
@ -205,7 +204,7 @@ export async function addFiles(
const fileId = createFileId(); const fileId = createFileId();
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation) // Track blob URLs for cleanup (images return blob URLs that need revocation)
@ -226,7 +225,7 @@ export async function addFiles(
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail }); addedFiles.push({ file, id: fileId, thumbnail });
} }
break; break;
@ -254,7 +253,7 @@ export async function addFiles(
filesRef.current.set(fileId, file); filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
// Generate processedFile metadata for stored files // Generate processedFile metadata for stored files
let pageCount: number = 1; let pageCount: number = 1;
@ -301,7 +300,7 @@ export async function addFiles(
} }
existingQuickKeys.add(quickKey); existingQuickKeys.add(quickKey);
fileRecords.push(record); stirlingFileStubs.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail }); addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
} }
@ -310,9 +309,9 @@ export async function addFiles(
} }
// Dispatch ADD_FILES action if we have new files // Dispatch ADD_FILES action if we have new files
if (fileRecords.length > 0) { if (stirlingFileStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } }); dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`); if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${stirlingFileStubs.length} files`);
} }
return addedFiles; return addedFiles;
@ -328,7 +327,7 @@ export async function addFiles(
async function processFilesIntoRecords( async function processFilesIntoRecords(
files: File[], files: File[],
filesRef: React.MutableRefObject<Map<FileId, File>> filesRef: React.MutableRefObject<Map<FileId, File>>
): Promise<Array<{ record: FileRecord; file: File; fileId: FileId; thumbnail?: string }>> { ): Promise<Array<{ record: StirlingFileStub; file: File; fileId: FileId; thumbnail?: string }>> {
return Promise.all( return Promise.all(
files.map(async (file) => { files.map(async (file) => {
const fileId = createFileId(); const fileId = createFileId();
@ -347,7 +346,7 @@ async function processFilesIntoRecords(
if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error); if (DEBUG) console.warn(`📄 Failed to generate thumbnail for file ${file.name}:`, error);
} }
const record = toFileRecord(file, fileId); const record = toStirlingFileStub(file, fileId);
if (thumbnail) { if (thumbnail) {
record.thumbnailUrl = thumbnail; record.thumbnailUrl = thumbnail;
} }
@ -365,10 +364,10 @@ async function processFilesIntoRecords(
* Helper function to persist files to IndexedDB * Helper function to persist files to IndexedDB
*/ */
async function persistFilesToIndexedDB( async function persistFilesToIndexedDB(
fileRecords: Array<{ file: File; fileId: FileId; thumbnail?: string }>, stirlingFileStubs: Array<{ file: File; fileId: FileId; thumbnail?: string }>,
indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } indexedDB: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> }
): Promise<void> { ): Promise<void> {
await Promise.all(fileRecords.map(async ({ file, fileId, thumbnail }) => { await Promise.all(stirlingFileStubs.map(async ({ file, fileId, thumbnail }) => {
try { try {
await indexedDB.saveFile(file, fileId, thumbnail); await indexedDB.saveFile(file, fileId, thumbnail);
} catch (error) { } catch (error) {
@ -383,7 +382,6 @@ async function persistFilesToIndexedDB(
export async function consumeFiles( export async function consumeFiles(
inputFileIds: FileId[], inputFileIds: FileId[],
outputFiles: File[], outputFiles: File[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>, dispatch: React.Dispatch<FileContextAction>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any> } | null
@ -391,11 +389,11 @@ export async function consumeFiles(
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`); if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
// Process output files with thumbnails and metadata // Process output files with thumbnails and metadata
const outputFileRecords = await processFilesIntoRecords(outputFiles, filesRef); const outputStirlingFileStubs = await processFilesIntoRecords(outputFiles, filesRef);
// Persist output files to IndexedDB if available // Persist output files to IndexedDB if available
if (indexedDB) { if (indexedDB) {
await persistFilesToIndexedDB(outputFileRecords, indexedDB); await persistFilesToIndexedDB(outputStirlingFileStubs, indexedDB);
} }
// Dispatch the consume action // Dispatch the consume action
@ -403,21 +401,20 @@ export async function consumeFiles(
type: 'CONSUME_FILES', type: 'CONSUME_FILES',
payload: { payload: {
inputFileIds, inputFileIds,
outputFileRecords: outputFileRecords.map(({ record }) => record) outputStirlingFileStubs: outputStirlingFileStubs.map(({ record }) => record)
} }
}); });
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`); if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputStirlingFileStubs.length} outputs`);
// Return the output file IDs for undo tracking // Return the output file IDs for undo tracking
return outputFileRecords.map(({ fileId }) => fileId); return outputStirlingFileStubs.map(({ fileId }) => fileId);
} }
/** /**
* Helper function to restore files to filesRef and manage IndexedDB cleanup * Helper function to restore files to filesRef and manage IndexedDB cleanup
*/ */
async function restoreFilesAndCleanup( async function restoreFilesAndCleanup(
filesToRestore: Array<{ file: File; record: FileRecord }>, filesToRestore: Array<{ file: File; record: StirlingFileStub }>,
fileIdsToRemove: FileId[], fileIdsToRemove: FileId[],
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null indexedDB?: { deleteFile: (fileId: FileId) => Promise<void> } | null
@ -440,7 +437,7 @@ async function restoreFilesAndCleanup(
if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`); if (DEBUG) console.warn(`📄 Skipping empty file ${file.name}`);
return; return;
} }
// Restore the file to filesRef // Restore the file to filesRef
if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`); if (DEBUG) console.log(`📄 Restoring file ${file.name} with id ${record.id} to filesRef`);
filesRef.current.set(record.id, file); filesRef.current.set(record.id, file);
@ -455,7 +452,7 @@ async function restoreFilesAndCleanup(
throw error; // Re-throw to trigger rollback throw error; // Re-throw to trigger rollback
}) })
); );
// Execute all IndexedDB operations // Execute all IndexedDB operations
await Promise.all(indexedDBPromises); await Promise.all(indexedDBPromises);
} }
@ -466,28 +463,27 @@ async function restoreFilesAndCleanup(
*/ */
export async function undoConsumeFiles( export async function undoConsumeFiles(
inputFiles: File[], inputFiles: File[],
inputFileRecords: FileRecord[], inputStirlingFileStubs: StirlingFileStub[],
outputFileIds: FileId[], outputFileIds: FileId[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>, filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>, dispatch: React.Dispatch<FileContextAction>,
indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null indexedDB?: { saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<any>; deleteFile: (fileId: FileId) => Promise<void> } | null
): Promise<void> { ): Promise<void> {
if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputFileRecords.length} input files, removing ${outputFileIds.length} output files`); if (DEBUG) console.log(`📄 undoConsumeFiles: Restoring ${inputStirlingFileStubs.length} input files, removing ${outputFileIds.length} output files`);
// Validate inputs // Validate inputs
if (inputFiles.length !== inputFileRecords.length) { if (inputFiles.length !== inputStirlingFileStubs.length) {
throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputFileRecords.length})`); throw new Error(`Mismatch between input files (${inputFiles.length}) and records (${inputStirlingFileStubs.length})`);
} }
// Create a backup of current filesRef state for rollback // Create a backup of current filesRef state for rollback
const backupFilesRef = new Map(filesRef.current); const backupFilesRef = new Map(filesRef.current);
try { try {
// Prepare files to restore // Prepare files to restore
const filesToRestore = inputFiles.map((file, index) => ({ const filesToRestore = inputFiles.map((file, index) => ({
file, file,
record: inputFileRecords[index] record: inputStirlingFileStubs[index]
})); }));
// Restore input files and clean up output files // Restore input files and clean up output files
@ -502,13 +498,12 @@ export async function undoConsumeFiles(
dispatch({ dispatch({
type: 'UNDO_CONSUME_FILES', type: 'UNDO_CONSUME_FILES',
payload: { payload: {
inputFileRecords, inputStirlingFileStubs,
outputFileIds outputFileIds
} }
}); });
if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputFileRecords.length} inputs, removed ${outputFileIds.length} outputs`); if (DEBUG) console.log(`📄 undoConsumeFiles: Successfully undone consume operation - restored ${inputStirlingFileStubs.length} inputs, removed ${outputFileIds.length} outputs`);
} catch (error) { } catch (error) {
// Rollback filesRef to previous state // Rollback filesRef to previous state
if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error); if (DEBUG) console.error('📄 undoConsumeFiles: Error during undo, rolling back filesRef', error);

View File

@ -9,7 +9,7 @@ import {
FileContextStateValue, FileContextStateValue,
FileContextActionsValue FileContextActionsValue
} from './contexts'; } from './contexts';
import { FileRecord } from '../../types/fileContext'; import { StirlingFileStub, StirlingFile } from '../../types/fileContext';
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
/** /**
@ -38,13 +38,13 @@ export function useFileActions(): FileContextActionsValue {
/** /**
* Hook for current/primary file (first in list) * Hook for current/primary file (first in list)
*/ */
export function useCurrentFile(): { file?: File; record?: FileRecord } { export function useCurrentFile(): { file?: File; record?: StirlingFileStub } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0]; const primaryFileId = state.files.ids[0];
return useMemo(() => ({ return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined, file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined record: primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : undefined
}), [primaryFileId, selectors]); }), [primaryFileId, selectors]);
} }
@ -87,7 +87,7 @@ export function useFileManagement() {
addFiles: actions.addFiles, addFiles: actions.addFiles,
removeFiles: actions.removeFiles, removeFiles: actions.removeFiles,
clearAllFiles: actions.clearAllFiles, clearAllFiles: actions.clearAllFiles,
updateFileRecord: actions.updateFileRecord, updateStirlingFileStub: actions.updateStirlingFileStub,
reorderFiles: actions.reorderFiles reorderFiles: actions.reorderFiles
}), [actions]); }), [actions]);
} }
@ -111,24 +111,24 @@ export function useFileUI() {
/** /**
* Hook for specific file by ID (optimized for individual file access) * Hook for specific file by ID (optimized for individual file access)
*/ */
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } { export function useStirlingFileStub(fileId: FileId): { file?: File; record?: StirlingFileStub } {
const { selectors } = useFileState(); const { selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
file: selectors.getFile(fileId), file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId) record: selectors.getStirlingFileStub(fileId)
}), [fileId, selectors]); }), [fileId, selectors]);
} }
/** /**
* Hook for all files (use sparingly - causes re-renders on file list changes) * Hook for all files (use sparingly - causes re-renders on file list changes)
*/ */
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { export function useAllFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
files: selectors.getFiles(), files: selectors.getFiles(),
records: selectors.getFileRecords(), records: selectors.getStirlingFileStubs(),
fileIds: state.files.ids fileIds: state.files.ids
}), [state.files.ids, selectors]); }), [state.files.ids, selectors]);
} }
@ -136,12 +136,12 @@ export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds:
/** /**
* Hook for selected files (optimized for selection-based UI) * Hook for selected files (optimized for selection-based UI)
*/ */
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } { export function useSelectedFiles(): { files: StirlingFile[]; records: StirlingFileStub[]; fileIds: FileId[] } {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
return useMemo(() => ({ return useMemo(() => ({
files: selectors.getSelectedFiles(), files: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(), records: selectors.getSelectedStirlingFileStubs(),
fileIds: state.ui.selectedFileIds fileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]); }), [state.ui.selectedFileIds, selectors]);
} }
@ -166,9 +166,9 @@ export function useFileContext() {
addFiles: actions.addFiles, addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles, consumeFiles: actions.consumeFiles,
undoConsumeFiles: actions.undoConsumeFiles, undoConsumeFiles: actions.undoConsumeFiles,
recordOperation: (fileId: FileId, operation: any) => {}, // Operation tracking not implemented recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: FileId, operationId: string) => {}, // Operation tracking not implemented markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: FileId, operationId: string, error: string) => {}, // Operation tracking not implemented markOperationFailed: (_fileId: FileId, _operationId: string, _error: string) => {}, // Operation tracking not implemented
// File ID lookup // File ID lookup
findFileId: (file: File) => { findFileId: (file: File) => {

View File

@ -4,9 +4,11 @@
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
import { import {
FileRecord, StirlingFileStub,
FileContextState, FileContextState,
FileContextSelectors FileContextSelectors,
StirlingFile,
createStirlingFile
} from '../../types/fileContext'; } from '../../types/fileContext';
/** /**
@ -17,16 +19,24 @@ export function createFileSelectors(
filesRef: React.MutableRefObject<Map<FileId, File>> filesRef: React.MutableRefObject<Map<FileId, File>>
): FileContextSelectors { ): FileContextSelectors {
return { return {
getFile: (id: FileId) => filesRef.current.get(id), getFile: (id: FileId) => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
},
getFiles: (ids?: FileId[]) => { getFiles: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids; const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[]; return currentIds
.map(id => {
const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
}, },
getFileRecord: (id: FileId) => stateRef.current.files.byId[id], getStirlingFileStub: (id: FileId) => stateRef.current.files.byId[id],
getFileRecords: (ids?: FileId[]) => { getStirlingFileStubs: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids; const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean); return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
}, },
@ -35,11 +45,14 @@ export function createFileSelectors(
getSelectedFiles: () => { getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id)) .map(id => {
.filter(Boolean) as File[]; const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
}, },
getSelectedFileRecords: () => { getSelectedStirlingFileStubs: () => {
return stateRef.current.ui.selectedFileIds return stateRef.current.ui.selectedFileIds
.map(id => stateRef.current.files.byId[id]) .map(id => stateRef.current.files.byId[id])
.filter(Boolean); .filter(Boolean);
@ -52,26 +65,21 @@ export function createFileSelectors(
getPinnedFiles: () => { getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles) return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id)) .map(id => {
.filter(Boolean) as File[]; const file = filesRef.current.get(id);
return file ? createStirlingFile(file, id) : undefined;
})
.filter(Boolean) as StirlingFile[];
}, },
getPinnedFileRecords: () => { getPinnedStirlingFileStubs: () => {
return Array.from(stateRef.current.pinnedFiles) return Array.from(stateRef.current.pinnedFiles)
.map(id => stateRef.current.files.byId[id]) .map(id => stateRef.current.files.byId[id])
.filter(Boolean); .filter(Boolean);
}, },
isFilePinned: (file: File) => { isFilePinned: (file: StirlingFile) => {
// Find FileId by matching File object properties return stateRef.current.pinnedFiles.has(file.fileId);
const fileId = (Object.keys(stateRef.current.files.byId) as FileId[]).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 // Stable signature for effects - prevents unnecessary re-renders
@ -90,9 +98,9 @@ export function createFileSelectors(
/** /**
* Helper for building quickKey sets for deduplication * Helper for building quickKey sets for deduplication
*/ */
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> { export function buildQuickKeySet(stirlingFileStubs: Record<FileId, StirlingFileStub>): Set<string> {
const quickKeys = new Set<string>(); const quickKeys = new Set<string>();
Object.values(fileRecords).forEach(record => { Object.values(stirlingFileStubs).forEach(record => {
if (record.quickKey) { if (record.quickKey) {
quickKeys.add(record.quickKey); quickKeys.add(record.quickKey);
} }
@ -119,7 +127,7 @@ export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; siz
export function getPrimaryFile( export function getPrimaryFile(
stateRef: React.MutableRefObject<FileContextState>, stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>> filesRef: React.MutableRefObject<Map<FileId, File>>
): { file?: File; record?: FileRecord } { ): { file?: File; record?: StirlingFileStub } {
const primaryFileId = stateRef.current.files.ids[0]; const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {}; if (!primaryFileId) return {};

View File

@ -3,7 +3,7 @@
*/ */
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
import { FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext'; import { FileContextAction, StirlingFileStub, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development'; const DEBUG = process.env.NODE_ENV === 'development';
@ -50,7 +50,7 @@ export class FileLifecycleManager {
this.blobUrls.forEach(url => { this.blobUrls.forEach(url => {
try { try {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} catch (error) { } catch {
// Ignore revocation errors // Ignore revocation errors
} }
}); });
@ -134,7 +134,7 @@ export class FileLifecycleManager {
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) { if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
try { try {
URL.revokeObjectURL(record.thumbnailUrl); URL.revokeObjectURL(record.thumbnailUrl);
} catch (error) { } catch {
// Ignore revocation errors // Ignore revocation errors
} }
} }
@ -142,18 +142,18 @@ export class FileLifecycleManager {
if (record.blobUrl && record.blobUrl.startsWith('blob:')) { if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try { try {
URL.revokeObjectURL(record.blobUrl); URL.revokeObjectURL(record.blobUrl);
} catch (error) { } catch {
// Ignore revocation errors // Ignore revocation errors
} }
} }
// Clean up processed file thumbnails // Clean up processed file thumbnails
if (record.processedFile?.pages) { if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => { record.processedFile.pages.forEach((page: ProcessedFilePage) => {
if (page.thumbnail && page.thumbnail.startsWith('blob:')) { if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
try { try {
URL.revokeObjectURL(page.thumbnail); URL.revokeObjectURL(page.thumbnail);
} catch (error) { } catch {
// Ignore revocation errors // Ignore revocation errors
} }
} }
@ -166,7 +166,7 @@ export class FileLifecycleManager {
/** /**
* Update file record with race condition guards * Update file record with race condition guards
*/ */
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => { updateStirlingFileStub = (fileId: FileId, updates: Partial<StirlingFileStub>, stateRef?: React.MutableRefObject<any>): void => {
// Guard against updating removed files (race condition protection) // Guard against updating removed files (race condition protection)
if (!this.filesRef.current.has(fileId)) { if (!this.filesRef.current.has(fileId)) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`); if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);

View File

@ -1,7 +1,7 @@
import { describe, expect, test, vi, beforeEach, MockedFunction } from 'vitest'; import { describe, expect, test, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react'; import { renderHook } from '@testing-library/react';
import { useAddPasswordOperation } from './useAddPasswordOperation'; import { useAddPasswordOperation } from './useAddPasswordOperation';
import type { AddPasswordFullParameters, AddPasswordParameters } from './useAddPasswordParameters'; import type { AddPasswordFullParameters } from './useAddPasswordParameters';
// Mock the useToolOperation hook // Mock the useToolOperation hook
vi.mock('../shared/useToolOperation', async () => { vi.mock('../shared/useToolOperation', async () => {

View File

@ -3,7 +3,6 @@ import { useCallback } from 'react';
import { executeAutomationSequence } from '../../../utils/automationExecutor'; import { executeAutomationSequence } from '../../../utils/automationExecutor';
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry'; import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
import { AutomateParameters } from '../../../types/automation'; import { AutomateParameters } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
export function useAutomateOperation() { export function useAutomateOperation() {
const toolRegistry = useFlatToolRegistry(); const toolRegistry = useFlatToolRegistry();

View File

@ -44,9 +44,9 @@ export function useSavedAutomations() {
const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => { const copyFromSuggested = useCallback(async (suggestedAutomation: SuggestedAutomation) => {
try { try {
const { automationStorage } = await import('../../../services/automationStorage'); const { automationStorage } = await import('../../../services/automationStorage');
// Map suggested automation icons to MUI icon keys // Map suggested automation icons to MUI icon keys
const getIconKey = (suggestedIcon: {id: string}): string => { const getIconKey = (_suggestedIcon: {id: string}): string => {
// Check the automation ID or name to determine the appropriate icon // Check the automation ID or name to determine the appropriate icon
switch (suggestedAutomation.id) { switch (suggestedAutomation.id) {
case 'secure-pdf-ingestion': case 'secure-pdf-ingestion':
@ -60,7 +60,7 @@ export function useSavedAutomations() {
return 'SettingsIcon'; // Default fallback return 'SettingsIcon'; // Default fallback
} }
}; };
// Convert suggested automation to saved automation format // Convert suggested automation to saved automation format
const savedAutomation = { const savedAutomation = {
name: suggestedAutomation.name, name: suggestedAutomation.name,
@ -68,7 +68,7 @@ export function useSavedAutomations() {
icon: getIconKey(suggestedAutomation.icon), icon: getIconKey(suggestedAutomation.icon),
operations: suggestedAutomation.operations operations: suggestedAutomation.operations
}; };
await automationStorage.saveAutomation(savedAutomation); await automationStorage.saveAutomation(savedAutomation);
// Refresh the list after saving // Refresh the list after saving
refreshAutomations(); refreshAutomations();
@ -91,4 +91,4 @@ export function useSavedAutomations() {
deleteAutomation, deleteAutomation,
copyFromSuggested copyFromSuggested
}; };
} }

View File

@ -6,7 +6,6 @@ import { SuggestedAutomation } from '../../../types/automation';
// Create icon components // Create icon components
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' }); const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
const TextFieldsIcon = () => React.createElement(LocalIcon, { icon: 'text-fields', width: '1.5rem', height: '1.5rem' });
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' }); const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' }); const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });

View File

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { CompressParameters, defaultParameters } from './useCompressParameters'; import { CompressParameters, defaultParameters } from './useCompressParameters';

View File

@ -2,9 +2,8 @@ import { useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ConvertParameters, defaultParameters } from './useConvertParameters'; import { ConvertParameters, defaultParameters } from './useConvertParameters';
import { detectFileExtension } from '../../../utils/fileUtils';
import { createFileFromApiResponse } from '../../../utils/fileResponseUtils'; import { createFileFromApiResponse } from '../../../utils/fileResponseUtils';
import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; import { useToolOperation, ToolType } from '../shared/useToolOperation';
import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils'; import { getEndpointUrl, isImageFormat, isWebFormat } from '../../../utils/convertUtils';
// Static function that can be used by both the hook and automation executor // Static function that can be used by both the hook and automation executor

View File

@ -2,7 +2,6 @@ import {
COLOR_TYPES, COLOR_TYPES,
OUTPUT_OPTIONS, OUTPUT_OPTIONS,
FIT_OPTIONS, FIT_OPTIONS,
TO_FORMAT_OPTIONS,
CONVERSION_MATRIX, CONVERSION_MATRIX,
type ColorType, type ColorType,
type OutputOption, type OutputOption,
@ -127,7 +126,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
endpointName: getEndpointName, endpointName: getEndpointName,
validateFn: validateParameters, validateFn: validateParameters,
}), []); }), []);
const baseHook = useBaseParameters(config); const baseHook = useBaseParameters(config);
const getEndpoint = () => { const getEndpoint = () => {
@ -166,7 +165,7 @@ export const useConvertParameters = (): ConvertParametersHook => {
if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') { if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') {
return prev; // No change needed return prev; // No change needed
} }
return { return {
...prev, ...prev,
isSmartDetection: false, isSmartDetection: false,
@ -290,13 +289,13 @@ export const useConvertParameters = (): ConvertParametersHook => {
// All files are images - use image-to-pdf conversion // All files are images - use image-to-pdf conversion
baseHook.setParameters(prev => { baseHook.setParameters(prev => {
// Only update if something actually changed // Only update if something actually changed
if (prev.isSmartDetection === true && if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'images' && prev.smartDetectionType === 'images' &&
prev.fromExtension === 'image' && prev.fromExtension === 'image' &&
prev.toExtension === 'pdf') { prev.toExtension === 'pdf') {
return prev; // No change needed return prev; // No change needed
} }
return { return {
...prev, ...prev,
isSmartDetection: true, isSmartDetection: true,
@ -309,13 +308,13 @@ export const useConvertParameters = (): ConvertParametersHook => {
// All files are web files - use html-to-pdf conversion // All files are web files - use html-to-pdf conversion
baseHook.setParameters(prev => { baseHook.setParameters(prev => {
// Only update if something actually changed // Only update if something actually changed
if (prev.isSmartDetection === true && if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'web' && prev.smartDetectionType === 'web' &&
prev.fromExtension === 'html' && prev.fromExtension === 'html' &&
prev.toExtension === 'pdf') { prev.toExtension === 'pdf') {
return prev; // No change needed return prev; // No change needed
} }
return { return {
...prev, ...prev,
isSmartDetection: true, isSmartDetection: true,
@ -328,13 +327,13 @@ export const useConvertParameters = (): ConvertParametersHook => {
// Mixed non-image types - use file-to-pdf conversion // Mixed non-image types - use file-to-pdf conversion
baseHook.setParameters(prev => { baseHook.setParameters(prev => {
// Only update if something actually changed // Only update if something actually changed
if (prev.isSmartDetection === true && if (prev.isSmartDetection === true &&
prev.smartDetectionType === 'mixed' && prev.smartDetectionType === 'mixed' &&
prev.fromExtension === 'any' && prev.fromExtension === 'any' &&
prev.toExtension === 'pdf') { prev.toExtension === 'pdf') {
return prev; // No change needed return prev; // No change needed
} }
return { return {
...prev, ...prev,
isSmartDetection: true, isSmartDetection: true,

View File

@ -4,7 +4,7 @@
*/ */
import { describe, test, expect } from 'vitest'; import { describe, test, expect } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from './useConvertParameters'; import { useConvertParameters } from './useConvertParameters';
describe('useConvertParameters - Auto Detection & Smart Conversion', () => { describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
@ -347,9 +347,9 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
const malformedFiles: Array<{name: string}> = [ const malformedFiles: Array<{name: string}> = [
{ name: 'valid.pdf' }, { name: 'valid.pdf' },
// @ts-ignore - Testing runtime resilience // @ts-expect-error - Testing runtime resilience
{ name: null }, { name: null },
// @ts-ignore // @ts-expect-error - Testing runtime resilience
{ name: undefined } { name: undefined }
]; ];

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters'; import { RemoveCertificateSignParameters, defaultParameters } from './useRemoveCertificateSignParameters';
// Static function that can be used by both the hook and automation executor // Static function that can be used by both the hook and automation executor
export const buildRemoveCertificateSignFormData = (parameters: RemoveCertificateSignParameters, file: File): FormData => { export const buildRemoveCertificateSignFormData = (_parameters: RemoveCertificateSignParameters, file: File): FormData => {
const formData = new FormData(); const formData = new FormData();
formData.append("fileInput", file); formData.append("fileInput", file);
return formData; return formData;

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { RepairParameters, defaultParameters } from './useRepairParameters'; import { RepairParameters, defaultParameters } from './useRepairParameters';
// Static function that can be used by both the hook and automation executor // Static function that can be used by both the hook and automation executor
export const buildRepairFormData = (parameters: RepairParameters, file: File): FormData => { export const buildRepairFormData = (_parameters: RepairParameters, file: File): FormData => {
const formData = new FormData(); const formData = new FormData();
formData.append("fileInput", file); formData.append("fileInput", file);
return formData; return formData;

View File

@ -4,10 +4,11 @@ import { useEndpointEnabled } from '../../useEndpointConfig';
import { BaseToolProps } from '../../../types/tool'; import { BaseToolProps } from '../../../types/tool';
import { ToolOperationHook } from './useToolOperation'; import { ToolOperationHook } from './useToolOperation';
import { BaseParametersHook } from './useBaseParameters'; import { BaseParametersHook } from './useBaseParameters';
import { StirlingFile } from '../../../types/fileContext';
interface BaseToolReturn<TParams> { interface BaseToolReturn<TParams> {
// File management // File management
selectedFiles: File[]; selectedFiles: StirlingFile[];
// Tool-specific hooks // Tool-specific hooks
params: BaseParametersHook<TParams>; params: BaseParametersHook<TParams>;

View File

@ -6,10 +6,8 @@ import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources'; import { useToolResources } from './useToolResources';
import { extractErrorMessage } from '../../../utils/toolErrorHandler'; import { extractErrorMessage } from '../../../utils/toolErrorHandler';
import { createOperation } from '../../../utils/toolOperationTracker'; import { StirlingFile, extractFiles, FileId, StirlingFileStub } from '../../../types/fileContext';
import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { ResponseHandler } from '../../../utils/toolResponseProcessor';
import { FileId } from '../../../types/file';
import { FileRecord } from '../../../types/fileContext';
// Re-export for backwards compatibility // Re-export for backwards compatibility
export type { ProcessingProgress, ResponseHandler }; export type { ProcessingProgress, ResponseHandler };
@ -104,7 +102,7 @@ export interface ToolOperationHook<TParams = void> {
progress: ProcessingProgress | null; progress: ProcessingProgress | null;
// Actions // Actions
executeOperation: (params: TParams, selectedFiles: File[]) => Promise<void>; executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
resetResults: () => void; resetResults: () => void;
clearError: () => void; clearError: () => void;
cancelOperation: () => void; cancelOperation: () => void;
@ -130,7 +128,7 @@ export const useToolOperation = <TParams>(
config: ToolOperationConfig<TParams> config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => { ): ToolOperationHook<TParams> => {
const { t } = useTranslation(); const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, undoConsumeFiles, findFileId, actions: fileActions, selectors } = useFileContext(); const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
// Composed hooks // Composed hooks
const { state, actions } = useToolState(); const { state, actions } = useToolState();
@ -140,13 +138,13 @@ export const useToolOperation = <TParams>(
// Track last operation for undo functionality // Track last operation for undo functionality
const lastOperationRef = useRef<{ const lastOperationRef = useRef<{
inputFiles: File[]; inputFiles: File[];
inputFileRecords: FileRecord[]; inputStirlingFileStubs: StirlingFileStub[];
outputFileIds: FileId[]; outputFileIds: FileId[];
} | null>(null); } | null>(null);
const executeOperation = useCallback(async ( const executeOperation = useCallback(async (
params: TParams, params: TParams,
selectedFiles: File[] selectedFiles: StirlingFile[]
): Promise<void> => { ): Promise<void> => {
// Validation // Validation
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
@ -160,9 +158,6 @@ export const useToolOperation = <TParams>(
return; return;
} }
// Setup operation tracking
const { operation, operationId, fileId } = createOperation(config.operationType, params, selectedFiles);
recordOperation(fileId, operation);
// Reset state // Reset state
actions.setLoading(true); actions.setLoading(true);
@ -173,8 +168,11 @@ export const useToolOperation = <TParams>(
try { try {
let processedFiles: File[]; let processedFiles: File[];
// Convert StirlingFile to regular File objects for API processing
const validRegularFiles = extractFiles(validFiles);
switch (config.toolType) { switch (config.toolType) {
case ToolType.singleFile: case ToolType.singleFile: {
// Individual file processing - separate API call per file // Individual file processing - separate API call per file
const apiCallsConfig: ApiCallsConfig<TParams> = { const apiCallsConfig: ApiCallsConfig<TParams> = {
endpoint: config.endpoint, endpoint: config.endpoint,
@ -184,17 +182,18 @@ export const useToolOperation = <TParams>(
}; };
processedFiles = await processFiles( processedFiles = await processFiles(
params, params,
validFiles, validRegularFiles,
apiCallsConfig, apiCallsConfig,
actions.setProgress, actions.setProgress,
actions.setStatus actions.setStatus
); );
break; break;
}
case ToolType.multiFile: case ToolType.multiFile: {
// Multi-file processing - single API call with all files // Multi-file processing - single API call with all files
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
const formData = config.buildFormData(params, validFiles); const formData = config.buildFormData(params, validRegularFiles);
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
const response = await axios.post(endpoint, formData, { responseType: 'blob' }); const response = await axios.post(endpoint, formData, { responseType: 'blob' });
@ -202,11 +201,11 @@ export const useToolOperation = <TParams>(
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs // Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
if (config.responseHandler) { if (config.responseHandler) {
// Use custom responseHandler for multi-file (handles ZIP extraction) // Use custom responseHandler for multi-file (handles ZIP extraction)
processedFiles = await config.responseHandler(response.data, validFiles); processedFiles = await config.responseHandler(response.data, validRegularFiles);
} else if (response.data.type === 'application/pdf' || } else if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) { (response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use original filename // Single PDF response (e.g. split with merge option) - use original filename
const originalFileName = validFiles[0]?.name || 'document.pdf'; const originalFileName = validRegularFiles[0]?.name || 'document.pdf';
const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' }); const singleFile = new File([response.data], originalFileName, { type: 'application/pdf' });
processedFiles = [singleFile]; processedFiles = [singleFile];
} else { } else {
@ -219,10 +218,11 @@ export const useToolOperation = <TParams>(
} }
} }
break; break;
}
case ToolType.custom: case ToolType.custom:
actions.setStatus('Processing files...'); actions.setStatus('Processing files...');
processedFiles = await config.customProcessor(params, validFiles); processedFiles = await config.customProcessor(params, validRegularFiles);
break; break;
} }
@ -242,46 +242,40 @@ export const useToolOperation = <TParams>(
// Replace input files with processed files (consumeFiles handles pinning) // Replace input files with processed files (consumeFiles handles pinning)
const inputFileIds: FileId[] = []; const inputFileIds: FileId[] = [];
const inputFileRecords: FileRecord[] = []; const inputStirlingFileStubs: StirlingFileStub[] = [];
// Build parallel arrays of IDs and records for undo tracking // Build parallel arrays of IDs and records for undo tracking
for (const file of validFiles) { for (const file of validFiles) {
const fileId = findFileId(file); const fileId = file.fileId;
if (fileId) { const record = selectors.getStirlingFileStub(fileId);
const record = selectors.getFileRecord(fileId); if (record) {
if (record) { inputFileIds.push(fileId);
inputFileIds.push(fileId); inputStirlingFileStubs.push(record);
inputFileRecords.push(record);
} else {
console.warn(`No file record found for file: ${file.name}`);
}
} else { } else {
console.warn(`No file ID found for file: ${file.name}`); console.warn(`No file stub found for file: ${file.name}`);
} }
} }
const outputFileIds = await consumeFiles(inputFileIds, processedFiles); const outputFileIds = await consumeFiles(inputFileIds, processedFiles);
// Store operation data for undo (only store what we need to avoid memory bloat) // Store operation data for undo (only store what we need to avoid memory bloat)
lastOperationRef.current = { lastOperationRef.current = {
inputFiles: validFiles, // Keep original File objects for undo inputFiles: extractFiles(validFiles), // Convert to File objects for undo
inputFileRecords: inputFileRecords.map(record => ({ ...record })), // Deep copy to avoid reference issues inputStirlingFileStubs: inputStirlingFileStubs.map(record => ({ ...record })), // Deep copy to avoid reference issues
outputFileIds outputFileIds
}; };
markOperationApplied(fileId, operationId);
} }
} catch (error: any) { } catch (error: any) {
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
actions.setError(errorMessage); actions.setError(errorMessage);
actions.setStatus(''); actions.setStatus('');
markOperationFailed(fileId, operationId, errorMessage);
} finally { } finally {
actions.setLoading(false); actions.setLoading(false);
actions.setProgress(null); actions.setProgress(null);
} }
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]); }, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
const cancelOperation = useCallback(() => { const cancelOperation = useCallback(() => {
cancelApiCalls(); cancelApiCalls();
@ -310,10 +304,10 @@ export const useToolOperation = <TParams>(
return; return;
} }
const { inputFiles, inputFileRecords, outputFileIds } = lastOperationRef.current; const { inputFiles, inputStirlingFileStubs, outputFileIds } = lastOperationRef.current;
// Validate that we have data to undo // Validate that we have data to undo
if (inputFiles.length === 0 || inputFileRecords.length === 0) { if (inputFiles.length === 0 || inputStirlingFileStubs.length === 0) {
actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data')); actions.setError(t('invalidUndoData', 'Cannot undo: invalid operation data'));
return; return;
} }
@ -325,18 +319,19 @@ export const useToolOperation = <TParams>(
try { try {
// Undo the consume operation // Undo the consume operation
await undoConsumeFiles(inputFiles, inputFileRecords, outputFileIds); await undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds);
// Clear results and operation tracking // Clear results and operation tracking
resetResults(); resetResults();
lastOperationRef.current = null; lastOperationRef.current = null;
// Show success message // Show success message
actions.setStatus(t('undoSuccess', 'Operation undone successfully')); actions.setStatus(t('undoSuccess', 'Operation undone successfully'));
} catch (error: any) { } catch (error: any) {
let errorMessage = extractErrorMessage(error); let errorMessage = extractErrorMessage(error);
// Provide more specific error messages based on error type // Provide more specific error messages based on error type
if (error.message?.includes('Mismatch between input files')) { if (error.message?.includes('Mismatch between input files')) {
errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted'); errorMessage = t('undoDataMismatch', 'Cannot undo: operation data is corrupted');
@ -345,9 +340,9 @@ export const useToolOperation = <TParams>(
} else if (error.name === 'QuotaExceededError') { } else if (error.name === 'QuotaExceededError') {
errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space'); errorMessage = t('undoQuotaError', 'Cannot undo: insufficient storage space');
} }
actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`); actions.setError(`${t('undoFailed', 'Failed to undo operation')}: ${errorMessage}`);
// Don't clear the operation data if undo failed - user might want to try again // Don't clear the operation data if undo failed - user might want to try again
} }
}, [undoConsumeFiles, resetResults, actions, t]); }, [undoConsumeFiles, resetResults, actions, t]);

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters'; import { SingleLargePageParameters, defaultParameters } from './useSingleLargePageParameters';
// Static function that can be used by both the hook and automation executor // Static function that can be used by both the hook and automation executor
export const buildSingleLargePageFormData = (parameters: SingleLargePageParameters, file: File): FormData => { export const buildSingleLargePageFormData = (_parameters: SingleLargePageParameters, file: File): FormData => {
const formData = new FormData(); const formData = new FormData();
formData.append("fileInput", file); formData.append("fileInput", file);
return formData; return formData;

View File

@ -71,7 +71,7 @@ export const useSplitOperation = () => {
// Custom response handler that extracts ZIP files // Custom response handler that extracts ZIP files
// Can't add to exported config because it requires access to the zip code so must be part of the hook // Can't add to exported config because it requires access to the zip code so must be part of the hook
const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise<File[]> => { const responseHandler = useCallback(async (blob: Blob, _originalFiles: File[]): Promise<File[]> => {
// Split operations return ZIP files with multiple PDF pages // Split operations return ZIP files with multiple PDF pages
return await extractZipFiles(blob); return await extractZipFiles(blob);
}, [extractZipFiles]); }, [extractZipFiles]);

View File

@ -4,7 +4,7 @@ import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters'; import { UnlockPdfFormsParameters, defaultParameters } from './useUnlockPdfFormsParameters';
// Static function that can be used by both the hook and automation executor // Static function that can be used by both the hook and automation executor
export const buildUnlockPdfFormsFormData = (parameters: UnlockPdfFormsParameters, file: File): FormData => { export const buildUnlockPdfFormsFormData = (_parameters: UnlockPdfFormsParameters, file: File): FormData => {
const formData = new FormData(); const formData = new FormData();
formData.append("fileInput", file); formData.append("fileInput", file);
return formData; return formData;

View File

@ -184,11 +184,6 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
// Force show after initialization // Force show after initialization
setTimeout(() => { setTimeout(() => {
window.CookieConsent.show(); window.CookieConsent.show();
// Debug: Check if modal elements exist
const ccMain = document.getElementById('cc-main');
const consentModal = document.querySelector('.cm-wrapper');
}, 200); }, 200);
} catch (error) { } catch (error) {

View File

@ -19,17 +19,17 @@ export function useEndpointEnabled(endpoint: string): {
setLoading(false); setLoading(false);
return; return;
} }
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`); const response = await fetch(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`); throw new Error(`Failed to check endpoint: ${response.status} ${response.statusText}`);
} }
const isEnabled: boolean = await response.json(); const isEnabled: boolean = await response.json();
setEnabled(isEnabled); setEnabled(isEnabled);
} catch (err) { } catch (err) {
@ -72,27 +72,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setLoading(false); setLoading(false);
return; return;
} }
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Use batch API for efficiency // Use batch API for efficiency
const endpointsParam = endpoints.join(','); const endpointsParam = endpoints.join(',');
const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`); const response = await fetch(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`); throw new Error(`Failed to check endpoints: ${response.status} ${response.statusText}`);
} }
const statusMap: Record<string, boolean> = await response.json(); const statusMap: Record<string, boolean> = await response.json();
setEndpointStatus(statusMap); setEndpointStatus(statusMap);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage); setError(errorMessage);
console.error('Failed to check multiple endpoints:', err); console.error('Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are disabled on error // Fallback: assume all endpoints are disabled on error
const fallbackStatus = endpoints.reduce((acc, endpoint) => { const fallbackStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = false; acc[endpoint] = false;
@ -105,7 +105,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}; };
useEffect(() => { useEffect(() => {
const endpointsKey = endpoints.join(',');
fetchAllEndpointStatuses(); fetchAllEndpointStatuses();
}, [endpoints.join(',')]); // Re-run when endpoints array changes }, [endpoints.join(',')]); // Re-run when endpoints array changes
@ -115,4 +114,4 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
error, error,
refetch: fetchAllEndpointStatuses, refetch: fetchAllEndpointStatuses,
}; };
} }

View File

@ -49,7 +49,7 @@ export function useEnhancedProcessedFiles(
// Process files when activeFiles changes // Process files when activeFiles changes
useEffect(() => { useEffect(() => {
console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files'); console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files');
if (activeFiles.length === 0) { if (activeFiles.length === 0) {
console.log('useEnhancedProcessedFiles: No active files, clearing processed cache'); console.log('useEnhancedProcessedFiles: No active files, clearing processed cache');
setProcessedFiles(new Map()); setProcessedFiles(new Map());
@ -60,15 +60,15 @@ export function useEnhancedProcessedFiles(
const processFiles = async () => { const processFiles = async () => {
const newProcessedFiles = new Map<File, ProcessedFile>(); const newProcessedFiles = new Map<File, ProcessedFile>();
for (const file of activeFiles) { for (const file of activeFiles) {
// Generate hash for this file // Generate hash for this file
const fileHash = await FileHasher.generateHybridHash(file); const fileHash = await FileHasher.generateHybridHash(file);
fileHashMapRef.current.set(file, fileHash); fileHashMapRef.current.set(file, fileHash);
// First, check if we have this exact File object cached // First, check if we have this exact File object cached
let existing = processedFiles.get(file); let existing = processedFiles.get(file);
// If not found by File object, try to find by hash in case File was recreated // If not found by File object, try to find by hash in case File was recreated
if (!existing) { if (!existing) {
for (const [cachedFile, processed] of processedFiles.entries()) { for (const [cachedFile, processed] of processedFiles.entries()) {
@ -79,7 +79,7 @@ export function useEnhancedProcessedFiles(
} }
} }
} }
if (existing) { if (existing) {
newProcessedFiles.set(file, existing); newProcessedFiles.set(file, existing);
continue; continue;
@ -94,11 +94,11 @@ export function useEnhancedProcessedFiles(
console.error(`Failed to start processing for ${file.name}:`, error); console.error(`Failed to start processing for ${file.name}:`, error);
} }
} }
// Only update if the content actually changed // Only update if the content actually changed
const hasChanged = newProcessedFiles.size !== processedFiles.size || const hasChanged = newProcessedFiles.size !== processedFiles.size ||
Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file)); Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file));
if (hasChanged) { if (hasChanged) {
setProcessedFiles(newProcessedFiles); setProcessedFiles(newProcessedFiles);
} }
@ -112,20 +112,20 @@ export function useEnhancedProcessedFiles(
const checkForCompletedFiles = async () => { const checkForCompletedFiles = async () => {
let hasNewFiles = false; let hasNewFiles = false;
const updatedFiles = new Map(processedFiles); const updatedFiles = new Map(processedFiles);
// Generate file keys for all files first // Generate file keys for all files first
const fileKeyPromises = activeFiles.map(async (file) => ({ const fileKeyPromises = activeFiles.map(async (file) => ({
file, file,
key: await FileHasher.generateHybridHash(file) key: await FileHasher.generateHybridHash(file)
})); }));
const fileKeyPairs = await Promise.all(fileKeyPromises); const fileKeyPairs = await Promise.all(fileKeyPromises);
for (const { file, key } of fileKeyPairs) { for (const { file, key } of fileKeyPairs) {
// Only check files that don't have processed results yet // Only check files that don't have processed results yet
if (!updatedFiles.has(file)) { if (!updatedFiles.has(file)) {
const processingState = processingStates.get(key); const processingState = processingStates.get(key);
// Check for both processing and recently completed files // Check for both processing and recently completed files
// This ensures we catch completed files before they're cleaned up // This ensures we catch completed files before they're cleaned up
if (processingState?.status === 'processing' || processingState?.status === 'completed') { if (processingState?.status === 'processing' || processingState?.status === 'completed') {
@ -135,13 +135,13 @@ export function useEnhancedProcessedFiles(
updatedFiles.set(file, processed); updatedFiles.set(file, processed);
hasNewFiles = true; hasNewFiles = true;
} }
} catch (error) { } catch {
// Ignore errors in completion check // Ignore errors in completion check
} }
} }
} }
} }
if (hasNewFiles) { if (hasNewFiles) {
setProcessedFiles(updatedFiles); setProcessedFiles(updatedFiles);
} }
@ -158,11 +158,11 @@ export function useEnhancedProcessedFiles(
const currentFiles = new Set(activeFiles); const currentFiles = new Set(activeFiles);
const previousFiles = Array.from(processedFiles.keys()); const previousFiles = Array.from(processedFiles.keys());
const removedFiles = previousFiles.filter(file => !currentFiles.has(file)); const removedFiles = previousFiles.filter(file => !currentFiles.has(file));
if (removedFiles.length > 0) { if (removedFiles.length > 0) {
// Clean up processing service cache // Clean up processing service cache
enhancedPDFProcessingService.cleanup(removedFiles); enhancedPDFProcessingService.cleanup(removedFiles);
// Update local state // Update local state
setProcessedFiles(prev => { setProcessedFiles(prev => {
const updated = new Map(); const updated = new Map();
@ -179,10 +179,10 @@ export function useEnhancedProcessedFiles(
// Calculate derived state // Calculate derived state
const isProcessing = processingStates.size > 0; const isProcessing = processingStates.size > 0;
const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error'); const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error');
// Calculate overall progress // Calculate overall progress
const processingProgress = calculateProcessingProgress(processingStates); const processingProgress = calculateProcessingProgress(processingStates);
// Get cache stats and metrics // Get cache stats and metrics
const cacheStats = enhancedPDFProcessingService.getCacheStats(); const cacheStats = enhancedPDFProcessingService.getCacheStats();
const metrics = enhancedPDFProcessingService.getMetrics(); const metrics = enhancedPDFProcessingService.getMetrics();
@ -192,7 +192,7 @@ export function useEnhancedProcessedFiles(
cancelProcessing: (fileKey: string) => { cancelProcessing: (fileKey: string) => {
enhancedPDFProcessingService.cancelProcessing(fileKey); enhancedPDFProcessingService.cancelProcessing(fileKey);
}, },
retryProcessing: async (file: File) => { retryProcessing: async (file: File) => {
try { try {
await enhancedPDFProcessingService.processFile(file, config); await enhancedPDFProcessingService.processFile(file, config);
@ -200,7 +200,7 @@ export function useEnhancedProcessedFiles(
console.error(`Failed to retry processing for ${file.name}:`, error); console.error(`Failed to retry processing for ${file.name}:`, error);
} }
}, },
clearCache: () => { clearCache: () => {
enhancedPDFProcessingService.clearAll(); enhancedPDFProcessingService.clearAll();
} }
@ -279,7 +279,7 @@ export function useEnhancedProcessedFile(
}; };
} { } {
const result = useEnhancedProcessedFiles(file ? [file] : [], config); const result = useEnhancedProcessedFiles(file ? [file] : [], config);
const processedFile = file ? result.processedFiles.get(file) || null : null; const processedFile = file ? result.processedFiles.get(file) || null : null;
// Note: This is async but we can't await in hook return - consider refactoring if needed // Note: This is async but we can't await in hook return - consider refactoring if needed
const fileKey = file ? '' : ''; const fileKey = file ? '' : '';
@ -309,4 +309,4 @@ export function useEnhancedProcessedFile(
canRetry, canRetry,
actions actions
}; };
} }

View File

@ -1,8 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useIndexedDB } from '../contexts/IndexedDBContext'; import { useIndexedDB } from '../contexts/IndexedDBContext';
import { FileMetadata } from '../types/file'; import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils'; import { FileId } from '../types/fileContext';
import { FileId } from '../types/file';
export const useFileManager = () => { export const useFileManager = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isFileObject } from '../types/fileContext';
/** /**
* Hook to convert a File object to { file: File; url: string } format * Hook to convert a File object to { file: File; url: string } format
@ -8,8 +9,8 @@ export function useFileWithUrl(file: File | Blob | null): { file: File | Blob; u
return useMemo(() => { return useMemo(() => {
if (!file) return null; if (!file) return null;
// Validate that file is a proper File or Blob object // Validate that file is a proper File, StirlingFile, or Blob object
if (!(file instanceof File) && !(file instanceof Blob)) { if (!isFileObject(file) && !(file instanceof Blob)) {
console.warn('useFileWithUrl: Expected File or Blob, got:', file); console.warn('useFileWithUrl: Expected File or Blob, got:', file);
return null; return null;
} }

View File

@ -2,21 +2,8 @@ import { useState, useEffect } from "react";
import { FileMetadata } from "../types/file"; import { FileMetadata } from "../types/file";
import { useIndexedDB } from "../contexts/IndexedDBContext"; import { useIndexedDB } from "../contexts/IndexedDBContext";
import { generateThumbnailForFile } from "../utils/thumbnailUtils"; import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import { FileId } from "../types/fileContext";
/**
* Calculate optimal scale for thumbnail generation
* Ensures high quality while preventing oversized renders
*/
function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
const maxWidth = 400; // Max thumbnail width
const maxHeight = 600; // Max thumbnail height
const scaleX = maxWidth / pageViewport.width;
const scaleY = maxHeight / pageViewport.height;
// Don't upscale, only downscale if needed
return Math.min(scaleX, scaleY, 1.0);
}
/** /**
* Hook for IndexedDB-aware thumbnail loading * Hook for IndexedDB-aware thumbnail loading
@ -53,7 +40,7 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
// Try to load file from IndexedDB using new context // Try to load file from IndexedDB using new context
if (file.id && indexedDB) { if (file.id && indexedDB) {
const loadedFile = await indexedDB.loadFile(file.id); const loadedFile = await indexedDB.loadFile(file.id as FileId);
if (!loadedFile) { if (!loadedFile) {
throw new Error('File not found in IndexedDB'); throw new Error('File not found in IndexedDB');
} }
@ -66,11 +53,11 @@ export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
const thumbnail = await generateThumbnailForFile(fileObject); const thumbnail = await generateThumbnailForFile(fileObject);
if (!cancelled) { if (!cancelled) {
setThumb(thumbnail); setThumb(thumbnail);
// Save thumbnail to IndexedDB for persistence // Save thumbnail to IndexedDB for persistence
if (file.id && indexedDB && thumbnail) { if (file.id && indexedDB && thumbnail) {
try { try {
await indexedDB.updateThumbnail(file.id, thumbnail); await indexedDB.updateThumbnail(file.id as FileId, thumbnail);
} catch (error) { } catch (error) {
console.warn('Failed to save thumbnail to IndexedDB:', error); console.warn('Failed to save thumbnail to IndexedDB:', error);
} }

View File

@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { PDFDocument, PDFPage } from '../types/pageEditor'; import { PDFDocument, PDFPage } from '../types/pageEditor';
import { pdfWorkerManager } from '../services/pdfWorkerManager'; import { pdfWorkerManager } from '../services/pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export function usePDFProcessor() { export function usePDFProcessor() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -75,7 +76,7 @@ export function usePDFProcessor() {
// Create pages without thumbnails initially - load them lazily // Create pages without thumbnails initially - load them lazily
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
originalPageNumber: i, originalPageNumber: i,
thumbnail: null, // Will be loaded lazily thumbnail: null, // Will be loaded lazily

View File

@ -1,13 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import * as pdfjsLib from 'pdfjs-dist';
import { pdfWorkerManager } from '../services/pdfWorkerManager'; import { pdfWorkerManager } from '../services/pdfWorkerManager';
import { StirlingFile } from '../types/fileContext';
export interface PdfSignatureDetectionResult { export interface PdfSignatureDetectionResult {
hasDigitalSignatures: boolean; hasDigitalSignatures: boolean;
isChecking: boolean; isChecking: boolean;
} }
export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionResult => { export const usePdfSignatureDetection = (files: StirlingFile[]): PdfSignatureDetectionResult => {
const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false); const [hasDigitalSignatures, setHasDigitalSignatures] = useState(false);
const [isChecking, setIsChecking] = useState(false); const [isChecking, setIsChecking] = useState(false);
@ -25,7 +25,7 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
for (const file of files) { for (const file of files) {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
try { try {
const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
@ -41,7 +41,7 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
if (foundSignature) break; if (foundSignature) break;
} }
// Clean up PDF document using worker manager // Clean up PDF document using worker manager
pdfWorkerManager.destroyDocument(pdf); pdfWorkerManager.destroyDocument(pdf);
} catch (error) { } catch (error) {
@ -65,4 +65,4 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
hasDigitalSignatures, hasDigitalSignatures,
isChecking isChecking
}; };
}; };

View File

@ -1,5 +1,6 @@
import { useCallback, useRef } from 'react'; import { useCallback } from 'react';
import { thumbnailGenerationService } from '../services/thumbnailGenerationService'; import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
import { createQuickKey } from '../types/fileContext';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
// Request queue to handle concurrent thumbnail requests // Request queue to handle concurrent thumbnail requests
@ -71,8 +72,8 @@ async function processRequestQueue() {
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`); console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
// Use file name as fileId for PDF document caching // Use quickKey for PDF document caching (same metadata, consistent format)
const fileId = file.name + '_' + file.size + '_' + file.lastModified as FileId; const fileId = createQuickKey(file) as FileId;
const results = await thumbnailGenerationService.generateThumbnails( const results = await thumbnailGenerationService.generateThumbnails(
fileId, fileId,

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry"; import { useFlatToolRegistry } from "../data/useTranslatedToolRegistry";
import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy"; import { getAllEndpoints, type ToolRegistryEntry } from "../data/toolsTaxonomy";
@ -20,15 +20,6 @@ export const useToolManagement = (): ToolManagementResult => {
// Build endpoints list from registry entries with fallback to legacy mapping // Build endpoints list from registry entries with fallback to legacy mapping
const baseRegistry = useFlatToolRegistry(); const baseRegistry = useFlatToolRegistry();
const registryDerivedEndpoints = useMemo(() => {
const endpointsByTool: Record<string, string[]> = {};
Object.entries(baseRegistry).forEach(([key, entry]) => {
if (entry.endpoints && entry.endpoints.length > 0) {
endpointsByTool[key] = entry.endpoints;
}
});
return endpointsByTool;
}, [baseRegistry]);
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]); const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints); const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);

View File

@ -10,8 +10,8 @@ type ToolParameterValues = Record<string, any>;
* Register tool parameters and get current values * Register tool parameters and get current values
*/ */
export function useToolParameters( export function useToolParameters(
toolName: string, _toolName: string,
parameters: Record<string, any> _parameters: Record<string, any>
): [ToolParameterValues, (updates: Partial<ToolParameterValues>) => void] { ): [ToolParameterValues, (updates: Partial<ToolParameterValues>) => void] {
// Return empty values and noop updater // Return empty values and noop updater
@ -30,9 +30,9 @@ export function useToolParameter<T = any>(
definition: any definition: any
): [T, (value: T) => void] { ): [T, (value: T) => void] {
const [allParams, updateParams] = useToolParameters(toolName, { [paramName]: definition }); const [allParams, updateParams] = useToolParameters(toolName, { [paramName]: definition });
const value = allParams[paramName] as T; const value = allParams[paramName] as T;
const setValue = useCallback((newValue: T) => { const setValue = useCallback((newValue: T) => {
updateParams({ [paramName]: newValue }); updateParams({ [paramName]: newValue });
}, [paramName, updateParams]); }, [paramName, updateParams]);
@ -48,4 +48,4 @@ export function useGlobalParameters() {
const updateParameters = useCallback(() => {}, []); const updateParameters = useCallback(() => {}, []);
return [currentValues, updateParameters]; return [currentValues, updateParameters];
} }

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { clamp } from '../utils/genericUtils'; import { clamp } from '../utils/genericUtils';
import { getSidebarInfo } from '../utils/sidebarUtils'; import { getSidebarInfo } from '../utils/sidebarUtils';
import { SidebarRefs, SidebarState } from '../types/sidebar'; import { SidebarRefs, SidebarState } from '../types/sidebar';
@ -65,10 +65,10 @@ export function useTooltipPosition({
sidebarRefs?: SidebarRefs; sidebarRefs?: SidebarRefs;
sidebarState?: SidebarState; sidebarState?: SidebarState;
}): PositionState { }): PositionState {
const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({ const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({
top: 0, top: 0,
left: 0, left: 0,
arrowOffset: null arrowOffset: null
}); });
const [positionReady, setPositionReady] = useState(false); const [positionReady, setPositionReady] = useState(false);
@ -174,4 +174,4 @@ export function useTooltipPosition({
}, [open, sidebarLeft, position, gap, sidebarTooltip]); }, [open, sidebarLeft, position, gap, sidebarTooltip]);
return { coords, positionReady }; return { coords, positionReady };
} }

View File

@ -35,8 +35,11 @@ function updatePosthogConsent(){
return; return;
} }
const optIn = (window.CookieConsent as any).acceptedCategory('analytics'); const optIn = (window.CookieConsent as any).acceptedCategory('analytics');
optIn? if (optIn) {
posthog.opt_in_capturing() : posthog.opt_out_capturing(); posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated analytics consent: ", optIn? "opted in" : "opted out"); console.log("Updated analytics consent: ", optIn? "opted in" : "opted out");
} }

View File

@ -1,4 +1,3 @@
import React, { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "../contexts/ToolWorkflowContext"; import { useToolWorkflow } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
@ -11,7 +10,6 @@ import Workbench from "../components/layout/Workbench";
import QuickAccessBar from "../components/shared/QuickAccessBar"; import QuickAccessBar from "../components/shared/QuickAccessBar";
import RightRail from "../components/shared/RightRail"; import RightRail from "../components/shared/RightRail";
import FileManager from "../components/FileManager"; import FileManager from "../components/FileManager";
import Footer from "../components/shared/Footer";
export default function HomePage() { export default function HomePage() {

View File

@ -1,10 +1,10 @@
import * as pdfjsLib from 'pdfjs-dist'; import { ProcessedFile, ProcessingState, PDFPage, ProcessingConfig, ProcessingMetrics } from '../types/processing';
import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing';
import { ProcessingCache } from './processingCache'; import { ProcessingCache } from './processingCache';
import { FileHasher } from '../utils/fileHash'; import { FileHasher } from '../utils/fileHash';
import { FileAnalyzer } from './fileAnalyzer'; import { FileAnalyzer } from './fileAnalyzer';
import { ProcessingErrorHandler } from './processingErrorHandler'; import { ProcessingErrorHandler } from './processingErrorHandler';
import { pdfWorkerManager } from './pdfWorkerManager'; import { pdfWorkerManager } from './pdfWorkerManager';
import { createQuickKey } from '../types/fileContext';
export class EnhancedPDFProcessingService { export class EnhancedPDFProcessingService {
private static instance: EnhancedPDFProcessingService; private static instance: EnhancedPDFProcessingService;
@ -182,7 +182,7 @@ export class EnhancedPDFProcessingService {
): Promise<ProcessedFile> { ): Promise<ProcessedFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(arrayBuffer); const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
try { try {
const totalPages = pdf.numPages; const totalPages = pdf.numPages;
@ -201,7 +201,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail, thumbnail,
rotation: 0, rotation: 0,
@ -251,7 +251,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail, thumbnail,
rotation: 0, rotation: 0,
@ -266,7 +266,7 @@ export class EnhancedPDFProcessingService {
// Create placeholder pages for remaining pages // Create placeholder pages for remaining pages
for (let i = priorityCount + 1; i <= totalPages; i++) { for (let i = priorityCount + 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail: null, // Will be loaded lazily thumbnail: null, // Will be loaded lazily
rotation: 0, rotation: 0,
@ -313,7 +313,7 @@ export class EnhancedPDFProcessingService {
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality); const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail, thumbnail,
rotation: 0, rotation: 0,
@ -334,7 +334,7 @@ export class EnhancedPDFProcessingService {
// Create placeholders for remaining pages // Create placeholders for remaining pages
for (let i = firstChunkEnd + 1; i <= totalPages; i++) { for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail: null, thumbnail: null,
rotation: 0, rotation: 0,
@ -354,7 +354,7 @@ export class EnhancedPDFProcessingService {
*/ */
private async processMetadataOnly( private async processMetadataOnly(
file: File, file: File,
config: ProcessingConfig, _config: ProcessingConfig,
state: ProcessingState state: ProcessingState
): Promise<ProcessedFile> { ): Promise<ProcessedFile> {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
@ -368,7 +368,7 @@ export class EnhancedPDFProcessingService {
const pages: PDFPage[] = []; const pages: PDFPage[] = [];
for (let i = 1; i <= totalPages; i++) { for (let i = 1; i <= totalPages; i++) {
pages.push({ pages.push({
id: `${file.name}-page-${i}`, id: `${createQuickKey(file)}-page-${i}`,
pageNumber: i, pageNumber: i,
thumbnail: null, thumbnail: null,
rotation: 0, rotation: 0,
@ -459,11 +459,12 @@ export class EnhancedPDFProcessingService {
case 'failed': case 'failed':
this.metrics.failedFiles++; this.metrics.failedFiles++;
break; break;
case 'cacheHit': case 'cacheHit': {
// Update cache hit rate // Update cache hit rate
const totalAttempts = this.metrics.totalFiles + 1; const totalAttempts = this.metrics.totalFiles + 1;
this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts; this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts;
break; break;
}
} }
} }
@ -508,7 +509,7 @@ export class EnhancedPDFProcessingService {
*/ */
clearAllProcessing(): void { clearAllProcessing(): void {
// Cancel all ongoing processing // Cancel all ongoing processing
this.processing.forEach((state, key) => { this.processing.forEach((state) => {
if (state.cancellationToken) { if (state.cancellationToken) {
state.cancellationToken.abort(); state.cancellationToken.abort();
} }
@ -519,10 +520,7 @@ export class EnhancedPDFProcessingService {
this.notifyListeners(); this.notifyListeners();
// Force memory cleanup hint // Force memory cleanup hint
if (typeof window !== 'undefined' && window.gc) { setTimeout(() => window.gc?.(), 100);
let gc = window.gc;
setTimeout(() => gc(), 100);
}
} }
/** /**

View File

@ -128,7 +128,7 @@ export class FileAnalyzer {
* Estimate processing time based on file characteristics and strategy * Estimate processing time based on file characteristics and strategy
*/ */
private static estimateProcessingTime( private static estimateProcessingTime(
fileSize: number, _fileSize: number,
pageCount: number = 0, pageCount: number = 0,
strategy: ProcessingStrategy strategy: ProcessingStrategy
): number { ): number {
@ -148,15 +148,17 @@ export class FileAnalyzer {
case 'immediate_full': case 'immediate_full':
return pageCount * baseTime; return pageCount * baseTime;
case 'priority_pages': case 'priority_pages': {
// Estimate time for priority pages (first 10) // Estimate time for priority pages (first 10)
const priorityPages = Math.min(pageCount, 10); const priorityPages = Math.min(pageCount, 10);
return priorityPages * baseTime; return priorityPages * baseTime;
}
case 'progressive_chunked': case 'progressive_chunked': {
// Estimate time for first chunk (20 pages) // Estimate time for first chunk (20 pages)
const firstChunk = Math.min(pageCount, 20); const firstChunk = Math.min(pageCount, 20);
return firstChunk * baseTime; return firstChunk * baseTime;
}
default: default:
return pageCount * baseTime; return pageCount * baseTime;
@ -232,7 +234,7 @@ export class FileAnalyzer {
const headerString = String.fromCharCode(...headerBytes); const headerString = String.fromCharCode(...headerBytes);
return headerString.startsWith('%PDF-'); return headerString.startsWith('%PDF-');
} catch (error) { } catch {
return false; return false;
} }
} }

Some files were not shown because too many files have changed in this diff Show More