From fd52dc022649b2bce1e6fe00cb93b03c45d646ac Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Thu, 25 Sep 2025 21:03:53 +0100 Subject: [PATCH] Feature/toasts and error handling (#4496) # Description of Changes - Added error handling and toast notifications --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../SPDF/controller/api/MergeController.java | 64 +++- .../model/api/general/MergePdfsRequest.java | 6 + .../public/locales/en-GB/translation.json | 3 + .../public/locales/en-US/translation.json | 2 + .../fileEditor/FileEditor.module.css | 31 ++ .../src/components/fileEditor/FileEditor.tsx | 65 ++-- .../fileEditor/FileEditorThumbnail.tsx | 50 ++- frontend/src/components/layout/Workbench.tsx | 4 + .../shared/DismissAllErrorsButton.tsx | 51 +++ .../shared/RainbowThemeProvider.tsx | 9 +- frontend/src/components/shared/RightRail.tsx | 7 +- frontend/src/components/toast/Toast.README.md | 309 ++++++++++++++++++ .../src/components/toast/ToastContext.tsx | 150 +++++++++ .../src/components/toast/ToastRenderer.css | 209 ++++++++++++ .../src/components/toast/ToastRenderer.tsx | 138 ++++++++ frontend/src/components/toast/index.ts | 61 ++++ frontend/src/components/toast/types.ts | 50 +++ frontend/src/contexts/file/FileReducer.ts | 27 +- frontend/src/contexts/file/fileActions.ts | 5 +- .../tools/convert/useConvertOperation.ts | 2 + .../hooks/tools/merge/useMergeOperation.ts | 3 + .../src/hooks/tools/shared/useToolApiCalls.ts | 38 ++- .../hooks/tools/shared/useToolOperation.ts | 166 ++++++++-- frontend/src/services/errorUtils.ts | 47 +++ frontend/src/services/http.ts | 255 +++++++++++++++ frontend/src/services/specialErrorToasts.ts | 57 ++++ frontend/src/styles/theme.css | 53 ++- .../tests/convert/ConvertIntegration.test.tsx | 4 +- frontend/src/theme/mantineTheme.ts | 28 ++ frontend/src/types/fileContext.ts | 7 + frontend/src/utils/toolErrorHandler.ts | 2 +- frontend/tailwind.config.js | 36 ++ 32 files changed, 1845 insertions(+), 94 deletions(-) create mode 100644 frontend/src/components/shared/DismissAllErrorsButton.tsx create mode 100644 frontend/src/components/toast/Toast.README.md create mode 100644 frontend/src/components/toast/ToastContext.tsx create mode 100644 frontend/src/components/toast/ToastRenderer.css create mode 100644 frontend/src/components/toast/ToastRenderer.tsx create mode 100644 frontend/src/components/toast/index.ts create mode 100644 frontend/src/components/toast/types.ts create mode 100644 frontend/src/services/errorUtils.ts create mode 100644 frontend/src/services/http.ts create mode 100644 frontend/src/services/specialErrorToasts.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 6f835d5ba..806dfdc38 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.nio.file.attribute.BasicFileAttributes; @@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; import org.apache.pdfbox.pdmodel.interactive.form.PDField; import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.multipart.MultipartFile; @@ -111,6 +114,32 @@ public class MergeController { } } + // Parse client file IDs from JSON string + private String[] parseClientFileIds(String clientFileIds) { + if (clientFileIds == null || clientFileIds.trim().isEmpty()) { + return new String[0]; + } + try { + // Simple JSON array parsing - remove brackets and split by comma + String trimmed = clientFileIds.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + String inside = trimmed.substring(1, trimmed.length() - 1).trim(); + if (inside.isEmpty()) { + return new String[0]; + } + String[] parts = inside.split(","); + String[] result = new String[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = parts[i].trim().replaceAll("^\"|\"$", ""); + } + return result; + } + } catch (Exception e) { + log.warn("Failed to parse client file IDs: {}", clientFileIds, e); + } + return new String[0]; + } + // Adds a table of contents to the merged document using filenames as chapter titles private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) { // Create the document outline @@ -177,15 +206,48 @@ public class MergeController { PDFMergerUtility mergerUtility = new PDFMergerUtility(); long totalSize = 0; - for (MultipartFile multipartFile : files) { + List invalidIndexes = new ArrayList<>(); + for (int index = 0; index < files.length; index++) { + MultipartFile multipartFile = files[index]; totalSize += multipartFile.getSize(); File tempFile = GeneralUtils.convertMultipartFileToFile( multipartFile); // Convert MultipartFile to File filesToDelete.add(tempFile); // Add temp file to the list for later deletion + + // Pre-validate each PDF so we can report which one(s) are broken + // Use the original MultipartFile to avoid deleting the tempFile during validation + try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) { + // OK + } catch (IOException e) { + ExceptionUtils.logException("PDF pre-validate", e); + invalidIndexes.add(index); + } mergerUtility.addSource(tempFile); // Add source file to the merger utility } + if (!invalidIndexes.isEmpty()) { + // Parse client file IDs (always present from frontend) + String[] clientIds = parseClientFileIds(request.getClientFileIds()); + + // Map invalid indexes to client IDs + List errorFileIds = new ArrayList<>(); + for (Integer index : invalidIndexes) { + if (index < clientIds.length) { + errorFileIds.add(clientIds[index]); + } + } + + String payload = String.format( + "{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}", + errorFileIds.toString() + ); + + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .body(payload.getBytes(StandardCharsets.UTF_8)); + } + mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile(); mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath()); diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java index 75f75223e..2851f018f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/MergePdfsRequest.java @@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles { requiredMode = Schema.RequiredMode.NOT_REQUIRED, defaultValue = "false") private boolean generateToc = false; + + @Schema( + description = + "JSON array of client-provided IDs for each uploaded file (same order as fileInput)", + requiredMode = Schema.RequiredMode.NOT_REQUIRED) + private String clientFileIds; } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 6b2b527cf..c30bc7c55 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -74,7 +74,10 @@ }, "error": { "pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect", + "encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.", + "incorrectPasswordProvided": "The PDF password is incorrect or not provided.", "_value": "Error", + "dismissAllErrors": "Dismiss All Errors", "sorry": "Sorry for the issue!", "needHelp": "Need help / Found an issue?", "contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 68cb57546..ae23ddd73 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -68,6 +68,8 @@ }, "error": { "pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect", + "encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.", + "incorrectPasswordProvided": "The PDF password is incorrect or not provided.", "_value": "Error", "sorry": "Sorry for the issue!", "needHelp": "Need help / Found an issue?", diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/components/fileEditor/FileEditor.module.css index 344959b80..ccabc2fa7 100644 --- a/frontend/src/components/fileEditor/FileEditor.module.css +++ b/frontend/src/components/fileEditor/FileEditor.module.css @@ -56,6 +56,20 @@ border-bottom: 1px solid var(--header-selected-bg); } +/* Error highlight (transient) */ +.headerError { + background: var(--color-red-200); + color: var(--text-primary); + border-bottom: 2px solid var(--color-red-500); +} + +/* Unsupported (but not errored) header appearance */ +.headerUnsupported { + background: var(--unsupported-bar-bg); /* neutral gray */ + color: #FFFFFF; + border-bottom: 1px solid var(--unsupported-bar-border); +} + /* Selected border color in light mode */ :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { outline-color: var(--card-selected-border); @@ -80,6 +94,7 @@ .kebab { justify-self: end; + color: #FFFFFF !important; } /* Menu dropdown */ @@ -217,6 +232,22 @@ height: 20px; } +/* Error pill shown when a file failed processing */ +.errorPill { + margin-left: 1.75rem; + background: var(--color-red-500); + color: #ffffff; + padding: 4px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + min-width: 56px; + height: 20px; +} + /* Animations */ @keyframes pulse { 0%, 100% { diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index f03404eac..8dbd83480 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useMemo } from 'react'; import { - Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal + Text, Center, Box, LoadingOverlay, Stack, Group } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext'; @@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; import { FileId, StirlingFile } from '../../types/fileContext'; +import { alert } from '../toast'; import { downloadBlob } from '../../utils/downloadUtils'; @@ -46,8 +47,16 @@ const FileEditor = ({ // Get file selection context const { setSelectedFiles } = useFileSelection(); - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); + const [_status, _setStatus] = useState(null); + const [_error, _setError] = useState(null); + + // Toast helpers + const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => { + alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); + }, []); + const showError = useCallback((message: string) => { + alert({ alertType: 'error', title: 'Error', body: message, expandable: true }); + }, []); const [selectionMode, setSelectionMode] = useState(toolMode); // Enable selection mode automatically in tool mode @@ -82,7 +91,7 @@ const FileEditor = ({ // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { - setError(null); + _setError(null); try { const allExtractedFiles: File[] = []; @@ -157,18 +166,18 @@ const FileEditor = ({ // Show any errors if (errors.length > 0) { - setError(errors.join('\n')); + showError(errors.join('\n')); } // Process all extracted files if (allExtractedFiles.length > 0) { // Add files to context (they will be processed automatically) await addFiles(allExtractedFiles); - setStatus(`Added ${allExtractedFiles.length} files`); + showStatus(`Added ${allExtractedFiles.length} files`, 'success'); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; - setError(errorMessage); + showError(errorMessage); console.error('File processing error:', err); // Reset extraction progress on error @@ -206,7 +215,7 @@ const FileEditor = ({ } else { // Check if we've hit the selection limit if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - setStatus(`Maximum ${maxAllowed} files can be selected`); + showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); return; } newSelection = [...currentSelectedIds, contextFileId]; @@ -215,7 +224,7 @@ const FileEditor = ({ // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]); + }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); // File reordering handler for drag and drop @@ -271,8 +280,8 @@ const FileEditor = ({ // Update status const moveCount = filesToMove.length; - setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [activeStirlingFileStubs, reorderFiles, setStatus]); + showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); + }, [activeStirlingFileStubs, reorderFiles, _setStatus]); @@ -297,7 +306,7 @@ const FileEditor = ({ if (record && file) { downloadBlob(file, file.name); } - }, [activeStirlingFileStubs, selectors, setStatus]); + }, [activeStirlingFileStubs, selectors, _setStatus]); const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); @@ -314,10 +323,10 @@ const FileEditor = ({ try { // Use FileContext to handle loading stored files // The files are already in FileContext, just need to add them to active files - setStatus(`Loaded ${selectedFiles.length} files from storage`); + showStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { console.error('Error loading files from storage:', err); - setError('Failed to load some files from storage'); + showError('Failed to load some files from storage'); } }, []); @@ -408,7 +417,7 @@ const FileEditor = ({ onToggleFile={toggleFile} onDeleteFile={handleDeleteFile} onViewFile={handleViewFile} - onSetStatus={setStatus} + _onSetStatus={showStatus} onReorderFiles={handleReorderFiles} onDownloadFile={handleDownloadFile} toolMode={toolMode} @@ -428,31 +437,7 @@ const FileEditor = ({ onSelectFiles={handleLoadFromStorage} /> - {status && ( - - setStatus(null)} - style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }} - > - {status} - - - )} - - {error && ( - - setError(null)} - style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }} - > - {error} - - - )} + ); diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index f28713c73..355d37c51 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; +import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; @@ -12,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext'; import styles from './FileEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; +import { useFileState } from '../../contexts/file/fileHooks'; import { FileId } from '../../types/file'; import { formatFileSize } from '../../utils/fileUtils'; import ToolChain from '../shared/ToolChain'; @@ -27,7 +29,7 @@ interface FileEditorThumbnailProps { onToggleFile: (fileId: FileId) => void; onDeleteFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; - onSetStatus: (status: string) => void; + _onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; onDownloadFile: (fileId: FileId) => void; toolMode?: boolean; @@ -40,13 +42,15 @@ const FileEditorThumbnail = ({ selectedFiles, onToggleFile, onDeleteFile, - onSetStatus, + _onSetStatus, onReorderFiles, onDownloadFile, isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); - const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext(); + const { state } = useFileState(); + const hasError = state.ui.errorFileIds.includes(file.id); // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); @@ -187,9 +191,20 @@ const FileEditorThumbnail = ({ // ---- Card interactions ---- const handleCardClick = () => { if (!isSupported) return; + // Clear error state if file has an error (click to clear error) + if (hasError) { + try { fileActions.clearFileError(file.id); } catch (_e) { void _e; } + } onToggleFile(file.id); }; + // ---- Style helpers ---- + const getHeaderClassName = () => { + if (hasError) return styles.headerError; + if (!isSupported) return styles.headerUnsupported; + return isSelected ? styles.headerSelected : styles.headerResting; + }; + return (
{/* Header bar */}
{/* Logo/checkbox area */}
- {isSupported ? ( + {hasError ? ( +
+ {t('error._value', 'Error')} +
+ ) : isSupported ? ( onToggleFile(file.id)} @@ -263,10 +278,10 @@ const FileEditorThumbnail = ({ if (actualFile) { if (isPinned) { unpinFile(actualFile); - onSetStatus?.(`Unpinned ${file.name}`); + alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 }); } else { pinFile(actualFile); - onSetStatus?.(`Pinned ${file.name}`); + alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 }); } } setShowActions(false); @@ -278,7 +293,7 @@ const FileEditorThumbnail = ({
{/* Preview area */} -
+
{file.thumbnailUrl && ( + {/* Dismiss All Errors Button */} + + {/* Main content area */} = ({ className }) => { + const { t } = useTranslation(); + const { state } = useFileState(); + const { actions } = useFileActions(); + + // Check if there are any files in error state + const hasErrors = state.ui.errorFileIds.length > 0; + + // Don't render if there are no errors + if (!hasErrors) { + return null; + } + + const handleDismissAllErrors = () => { + actions.clearAllFileErrors(); + }; + + return ( + + + + ); +}; + +export default DismissAllErrorsButton; diff --git a/frontend/src/components/shared/RainbowThemeProvider.tsx b/frontend/src/components/shared/RainbowThemeProvider.tsx index 21c46cf72..e452538fb 100644 --- a/frontend/src/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/components/shared/RainbowThemeProvider.tsx @@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core'; import { useRainbowTheme } from '../../hooks/useRainbowTheme'; import { mantineTheme } from '../../theme/mantineTheme'; import rainbowStyles from '../../styles/rainbow.module.css'; +import { ToastProvider } from '../toast'; +import ToastRenderer from '../toast/ToastRenderer'; +import { ToastPortalBinder } from '../toast'; interface RainbowThemeContextType { themeMode: 'light' | 'dark' | 'rainbow'; @@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''} style={{ minHeight: '100vh' }} > - {children} + + + {children} + +
diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index e5223d42b..8e970e551 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon'; import './rightRail/RightRail.css'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useRightRail } from '../../contexts/RightRailContext'; -import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext'; +import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext'; import { useNavigationState } from '../../contexts/NavigationContext'; import { useTranslation } from 'react-i18next'; @@ -39,6 +39,7 @@ export default function RightRail() { // File state and selection const { state, selectors } = useFileState(); + const { actions: fileActions } = useFileContext(); const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection(); const { removeFiles } = useFileManagement(); @@ -70,6 +71,8 @@ export default function RightRail() { // Select all file IDs const allIds = state.files.ids; setSelectedFiles(allIds); + // Clear any previous error flags when selecting all + try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } return; } @@ -82,6 +85,8 @@ export default function RightRail() { const handleDeselectAll = useCallback(() => { if (currentView === 'fileEditor' || currentView === 'viewer') { setSelectedFiles([]); + // Clear any previous error flags when deselecting all + try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } return; } if (currentView === 'pageEditor') { diff --git a/frontend/src/components/toast/Toast.README.md b/frontend/src/components/toast/Toast.README.md new file mode 100644 index 000000000..fe8d15485 --- /dev/null +++ b/frontend/src/components/toast/Toast.README.md @@ -0,0 +1,309 @@ +# Toast Component + +A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior. + +--- + +## Highlights + +* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function. +* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware). +* πŸ“± **Expandable Content**: Collapsible toasts with chevron controls and smooth animations. +* ⚑ **Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4"). +* πŸ“Š **Progress Tracking**: Built-in progress bars with completion animations. +* πŸŽ›οΈ **Customizable**: Rich JSX content, buttons with callbacks, custom icons. +* πŸŒ™ **Themeable**: Uses CSS variables; supports light/dark mode out of the box. +* β™Ώ **Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support. +* πŸ”„ **Auto-dismiss**: Configurable duration with persistent popup option. +* πŸ“ **Positioning**: Four corner positions with proper stacking. + +--- + +## Behavior + +### Default +* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`. +* **Expandable**: Click chevron to expand/collapse body content (default: collapsed). +* **Coalescing**: Identical error toasts merge with count badges. +* **Progress**: Progress bars always visible when present, even when collapsed. + +### Error Handling +* **Network Errors**: Automatically caught by Axios and fetch interceptors. +* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses. +* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others. + +--- + +## Installation + +The toast system is already integrated at the app root. No additional setup required. + +```tsx +import { alert, updateToast, dismissToast } from '@/components/toast'; +``` + +--- + +## Basic Usage + +### Simple Notifications + +```tsx +// Success notification +alert({ + alertType: 'success', + title: 'File processed successfully', + body: 'Your document has been converted to PDF.' +}); + +// Error notification +alert({ + alertType: 'error', + title: 'Processing failed', + body: 'Unable to process the selected files.' +}); + +// Warning notification +alert({ + alertType: 'warning', + title: 'Low disk space', + body: 'Consider freeing up some storage space.' +}); + +// Neutral notification +alert({ + alertType: 'neutral', + title: 'Information', + body: 'This is a neutral notification.' +}); +``` + +### With Custom Content + +```tsx +// Rich JSX content with buttons +alert({ + alertType: 'success', + title: 'Download complete', + body: ( +
+

File saved to Downloads folder

+ +
+ ), + buttonText: 'View file', + buttonCallback: () => openFile(), + isPersistentPopup: true +}); +``` + +### Progress Tracking + +```tsx +// Show progress +const toastId = alert({ + alertType: 'neutral', + title: 'Processing files...', + body: 'Converting your documents', + progressBarPercentage: 0 +}); + +// Update progress +updateToast(toastId, { progressBarPercentage: 50 }); + +// Complete with success +updateToast(toastId, { + alertType: 'success', + title: 'Processing complete', + body: 'All files converted successfully', + progressBarPercentage: 100 +}); +``` + +### Custom Positioning + +```tsx +alert({ + alertType: 'error', + title: 'Connection lost', + body: 'Please check your internet connection.', + location: 'top-right' +}); +``` + +--- + +## API + +### `alert(options: ToastOptions)` + +The primary function for showing toasts. + +```ts +interface ToastOptions { + alertType?: 'success' | 'error' | 'warning' | 'neutral'; + title: string; + body?: React.ReactNode; + buttonText?: string; + buttonCallback?: () => void; + isPersistentPopup?: boolean; + location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + icon?: React.ReactNode; + progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent + durationMs?: number; + id?: string; + expandable?: boolean; +} +``` + +### `updateToast(id: string, options: Partial)` + +Update an existing toast. + +```tsx +const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 }); +updateToast(toastId, { progressBarPercentage: 75 }); +``` + +### `dismissToast(id: string)` + +Dismiss a specific toast. + +```tsx +dismissToast(toastId); +``` + +### `dismissAllToasts()` + +Dismiss all visible toasts. + +```tsx +dismissAllToasts(); +``` + +--- + +## Alert Types + +| Type | Color | Icon | Use Case | +|------|-------|------|----------| +| `success` | Green | βœ“ | Successful operations, completions | +| `error` | Red | βœ— | Failures, errors, exceptions | +| `warning` | Yellow | ⚠ | Warnings, cautions, low resources | +| `neutral` | Theme | β„Ή | Information, general messages | + +--- + +## Positioning + +| Location | Description | +|----------|-------------| +| `top-left` | Top-left corner | +| `top-right` | Top-right corner | +| `bottom-left` | Bottom-left corner | +| `bottom-right` | Bottom-right corner (default) | + +--- + +## Accessibility + +* Toasts use `role="status"` for screen readers. +* Chevron and close buttons have proper `aria-label` attributes. +* Keyboard navigation supported (Escape to dismiss). +* Focus management for interactive content. + +--- + +## Examples + +### File Processing Workflow + +```tsx +// Start processing +const toastId = alert({ + alertType: 'neutral', + title: 'Processing files...', + body: 'Converting 5 documents', + progressBarPercentage: 0, + isPersistentPopup: true +}); + +// Update progress +updateToast(toastId, { progressBarPercentage: 30 }); +updateToast(toastId, { progressBarPercentage: 60 }); + +// Complete successfully +updateToast(toastId, { + alertType: 'success', + title: 'Processing complete', + body: 'All 5 documents converted successfully', + progressBarPercentage: 100, + isPersistentPopup: false +}); +``` + +### Error with Action + +```tsx +alert({ + alertType: 'error', + title: 'Upload failed', + body: 'File size exceeds the 10MB limit.', + buttonText: 'Try again', + buttonCallback: () => retryUpload(), + isPersistentPopup: true +}); +``` + +### Non-expandable Toast + +```tsx +alert({ + alertType: 'success', + title: 'Settings saved', + body: 'Your preferences have been updated.', + expandable: false, + durationMs: 3000 +}); +``` + +### Custom Icon + +```tsx +alert({ + alertType: 'neutral', + title: 'New feature available', + body: 'Check out the latest updates.', + icon: +}); +``` + +--- + +## Integration + +### Network Error Handling + +The toast system automatically catches network errors from Axios and fetch requests: + +```tsx +// These automatically show error toasts +axios.post('/api/convert', formData); +fetch('/api/process', { method: 'POST', body: data }); +``` + +### Manual Error Handling + +```tsx +try { + await processFiles(); + alert({ alertType: 'success', title: 'Files processed' }); +} catch (error) { + alert({ + alertType: 'error', + title: 'Processing failed', + body: error.message + }); +} +``` + diff --git a/frontend/src/components/toast/ToastContext.tsx b/frontend/src/components/toast/ToastContext.tsx new file mode 100644 index 000000000..1b0cc1539 --- /dev/null +++ b/frontend/src/components/toast/ToastContext.tsx @@ -0,0 +1,150 @@ +import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react'; +import { ToastApi, ToastInstance, ToastOptions } from './types'; + +function normalizeProgress(value: number | undefined): number | undefined { + if (typeof value !== 'number' || Number.isNaN(value)) return undefined; + // Accept 0..1 as fraction or 0..100 as percent + if (value <= 1) return Math.max(0, Math.min(1, value)) * 100; + return Math.max(0, Math.min(100, value)); +} + +function generateId() { + return `toast_${Math.random().toString(36).slice(2, 9)}`; +} + +type DefaultOpts = Required> & + Partial>; + +const defaultOptions: DefaultOpts = { + alertType: 'neutral', + title: '', + isPersistentPopup: false, + location: 'bottom-right', + durationMs: 6000, +}; + +interface ToastContextShape extends ToastApi { + toasts: ToastInstance[]; +} + +const ToastContext = createContext(null); + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const timers = useRef>({}); + + const scheduleAutoDismiss = useCallback((toast: ToastInstance) => { + if (toast.isPersistentPopup) return; + window.clearTimeout(timers.current[toast.id]); + timers.current[toast.id] = window.setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== toast.id)); + }, toast.durationMs); + }, []); + + const show = useCallback((options) => { + const id = options.id || generateId(); + const hasButton = !!(options.buttonText && options.buttonCallback); + const merged: ToastInstance = { + ...defaultOptions, + ...options, + id, + progress: normalizeProgress(options.progressBarPercentage), + justCompleted: false, + expandable: hasButton ? false : (options.expandable !== false), + isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)), + createdAt: Date.now(), + } as ToastInstance; + setToasts(prev => { + // Coalesce duplicates by alertType + title + body text if no explicit id was provided + if (!options.id) { + const bodyText = typeof merged.body === 'string' ? merged.body : ''; + const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText); + if (existingIndex !== -1) { + const updated = [...prev]; + const existing = updated[existingIndex]; + const nextCount = (existing.count ?? 1) + 1; + updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() }; + return updated; + } + } + const next = [...prev.filter(t => t.id !== id), merged]; + return next; + }); + scheduleAutoDismiss(merged); + return id; + }, [scheduleAutoDismiss]); + + const update = useCallback((id, updates) => { + setToasts(prev => prev.map(t => { + if (t.id !== id) return t; + const progress = updates.progressBarPercentage !== undefined + ? normalizeProgress(updates.progressBarPercentage) + : t.progress; + + const next: ToastInstance = { + ...t, + ...updates, + progress, + } as ToastInstance; + + // Detect completion + if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) { + // On completion: finalize type as success unless explicitly provided otherwise + next.justCompleted = false; + if (!updates.alertType) { + next.alertType = 'success'; + } + } + + return next; + })); + }, []); + + const updateProgress = useCallback((id, progress) => { + update(id, { progressBarPercentage: progress }); + }, [update]); + + const dismiss = useCallback((id) => { + setToasts(prev => prev.filter(t => t.id !== id)); + window.clearTimeout(timers.current[id]); + delete timers.current[id]; + }, []); + + const dismissAll = useCallback(() => { + setToasts([]); + Object.values(timers.current).forEach(t => window.clearTimeout(t)); + timers.current = {}; + }, []); + + const value = useMemo(() => ({ + toasts, + show, + update, + updateProgress, + dismiss, + dismissAll, + }), [toasts, show, update, updateProgress, dismiss, dismissAll]); + + // Handle expand/collapse toggles from renderer without widening API + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail as { id: string } | undefined; + if (!detail?.id) return; + setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t)); + }; + window.addEventListener('toast:toggle', handler as EventListener); + return () => window.removeEventListener('toast:toggle', handler as EventListener); + }, []); + + return ( + {children} + ); +} + + diff --git a/frontend/src/components/toast/ToastRenderer.css b/frontend/src/components/toast/ToastRenderer.css new file mode 100644 index 000000000..9292c0532 --- /dev/null +++ b/frontend/src/components/toast/ToastRenderer.css @@ -0,0 +1,209 @@ +/* Toast Container Styles */ +.toast-container { + position: fixed; + z-index: 1200; + display: flex; + gap: 12px; + pointer-events: none; +} + +.toast-container--top-left { + top: 16px; + left: 16px; + flex-direction: column; +} + +.toast-container--top-right { + top: 16px; + right: 16px; + flex-direction: column; +} + +.toast-container--bottom-left { + bottom: 16px; + left: 16px; + flex-direction: column-reverse; +} + +.toast-container--bottom-right { + bottom: 16px; + right: 16px; + flex-direction: column-reverse; +} + +/* Toast Item Styles */ +.toast-item { + min-width: 320px; + max-width: 560px; + box-shadow: var(--shadow-lg); + border-radius: 16px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: auto; +} + +/* Toast Alert Type Colors */ +.toast-item--success { + background: var(--color-green-100); + color: var(--text-primary); + border: 1px solid var(--color-green-400); +} + +.toast-item--error { + background: var(--color-red-100); + color: var(--text-primary); + border: 1px solid var(--color-red-400); +} + +.toast-item--warning { + background: var(--color-yellow-100); + color: var(--text-primary); + border: 1px solid var(--color-yellow-400); +} + +.toast-item--neutral { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-default); +} + +/* Toast Header Row */ +.toast-header { + display: flex; + align-items: center; + gap: 12px; +} + +.toast-icon { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.toast-title-container { + font-weight: 700; + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.toast-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.08); + color: inherit; + font-size: 12px; + font-weight: 700; +} + +.toast-controls { + display: flex; + gap: 4px; + align-items: center; +} + +.toast-button { + width: 28px; + height: 28px; + border-radius: 999px; + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.toast-expand-button { + transform: rotate(0deg); + transition: transform 160ms ease; +} + +.toast-expand-button--expanded { + transform: rotate(180deg); +} + +/* Progress Bar */ +.toast-progress-container { + margin-top: 8px; + height: 6px; + background: var(--bg-muted); + border-radius: 999px; + overflow: hidden; +} + +.toast-progress-bar { + height: 100%; + transition: width 160ms ease; +} + +.toast-progress-bar--success { + background: var(--color-green-500); +} + +.toast-progress-bar--error { + background: var(--color-red-500); +} + +.toast-progress-bar--warning { + background: var(--color-yellow-500); +} + +.toast-progress-bar--neutral { + background: var(--color-gray-500); +} + +/* Toast Body */ +.toast-body { + font-size: 14px; + opacity: 0.9; + margin-top: 8px; +} + +/* Toast Action Button */ +.toast-action-container { + margin-top: 12px; + display: flex; + justify-content: flex-start; +} + +.toast-action-button { + padding: 8px 12px; + border-radius: 12px; + border: 1px solid; + background: transparent; + font-weight: 600; + cursor: pointer; + margin-left: auto; +} + +.toast-action-button--success { + color: var(--text-primary); + border-color: var(--color-green-400); +} + +.toast-action-button--error { + color: var(--text-primary); + border-color: var(--color-red-400); +} + +.toast-action-button--warning { + color: var(--text-primary); + border-color: var(--color-yellow-400); +} + +.toast-action-button--neutral { + color: var(--text-primary); + border-color: var(--border-default); +} diff --git a/frontend/src/components/toast/ToastRenderer.tsx b/frontend/src/components/toast/ToastRenderer.tsx new file mode 100644 index 000000000..b0108bfe9 --- /dev/null +++ b/frontend/src/components/toast/ToastRenderer.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { useToast } from './ToastContext'; +import { ToastInstance, ToastLocation } from './types'; +import { LocalIcon } from '../shared/LocalIcon'; +import './ToastRenderer.css'; + +const locationToClass: Record = { + 'top-left': 'toast-container--top-left', + 'top-right': 'toast-container--top-right', + 'bottom-left': 'toast-container--bottom-left', + 'bottom-right': 'toast-container--bottom-right', +}; + +function getToastItemClass(t: ToastInstance): string { + return `toast-item toast-item--${t.alertType}`; +} + +function getProgressBarClass(t: ToastInstance): string { + return `toast-progress-bar toast-progress-bar--${t.alertType}`; +} + +function getActionButtonClass(t: ToastInstance): string { + return `toast-action-button toast-action-button--${t.alertType}`; +} + +function getDefaultIconName(t: ToastInstance): string { + switch (t.alertType) { + case 'success': + return 'check-circle-rounded'; + case 'error': + return 'close-rounded'; + case 'warning': + return 'warning-rounded'; + case 'neutral': + default: + return 'info-rounded'; + } +} + +export default function ToastRenderer() { + const { toasts, dismiss } = useToast(); + + const grouped = toasts.reduce>((acc, t) => { + const key = t.location; + if (!acc[key]) acc[key] = [] as ToastInstance[]; + acc[key].push(t); + return acc; + }, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] }); + + return ( + <> + {(Object.keys(grouped) as ToastLocation[]).map((loc) => ( +
+ {grouped[loc].map(t => { + return ( +
+ {/* Top row: Icon + Title + Controls */} +
+ {/* Icon */} +
+ {t.icon ?? ( + + )} +
+ + {/* Title + count badge */} +
+ {t.title} + {typeof t.count === 'number' && t.count > 1 && ( + {t.count} + )} +
+ + {/* Controls */} +
+ {t.expandable && ( + + )} + +
+
+ {/* Progress bar - always show when present */} + {typeof t.progress === 'number' && ( +
+
+
+ )} + + {/* Body content - only show when expanded */} + {(t.isExpanded || !t.expandable) && ( +
+ {t.body} +
+ )} + + {/* Button - always show when present, positioned below body */} + {t.buttonText && t.buttonCallback && ( +
+ +
+ )} +
+ ); + })} +
+ ))} + + ); +} + + diff --git a/frontend/src/components/toast/index.ts b/frontend/src/components/toast/index.ts new file mode 100644 index 000000000..d0b1045f2 --- /dev/null +++ b/frontend/src/components/toast/index.ts @@ -0,0 +1,61 @@ +import { ToastOptions } from './types'; +import { useToast, ToastProvider } from './ToastContext'; +import ToastRenderer from './ToastRenderer'; + +export { useToast, ToastProvider, ToastRenderer }; + +// Global imperative API via module singleton +let _api: ReturnType | null = null; + +function createImperativeApi() { + const subscribers: Array<(fn: any) => void> = []; + let api: any = null; + return { + provide(instance: any) { + api = instance; + subscribers.splice(0).forEach(cb => cb(api)); + }, + get(): any | null { return api; }, + onReady(cb: (api: any) => void) { + if (api) cb(api); else subscribers.push(cb); + } + }; +} + +if (!_api) _api = createImperativeApi(); + +// Hook helper to wire context API back to singleton +export function ToastPortalBinder() { + const ctx = useToast(); + // Provide API once mounted + _api!.provide(ctx); + return null; +} + +export function alert(options: ToastOptions) { + if (_api?.get()) { + return _api.get()!.show(options); + } + // Queue until provider mounts + let id = ''; + _api?.onReady((api) => { id = api.show(options); }); + return id; +} + +export function updateToast(id: string, options: Partial) { + _api?.get()?.update(id, options); +} + +export function updateToastProgress(id: string, progress: number) { + _api?.get()?.updateProgress(id, progress); +} + +export function dismissToast(id: string) { + _api?.get()?.dismiss(id); +} + +export function dismissAllToasts() { + _api?.get()?.dismissAll(); +} + + diff --git a/frontend/src/components/toast/types.ts b/frontend/src/components/toast/types.ts new file mode 100644 index 000000000..aeb0c79a5 --- /dev/null +++ b/frontend/src/components/toast/types.ts @@ -0,0 +1,50 @@ +import { ReactNode } from 'react'; + +export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral'; + +export interface ToastOptions { + alertType?: ToastAlertType; + title: string; + body?: ReactNode; + buttonText?: string; + buttonCallback?: () => void; + isPersistentPopup?: boolean; + location?: ToastLocation; + icon?: ReactNode; + /** number 0-1 as fraction or 0-100 as percent */ + progressBarPercentage?: number; + /** milliseconds to auto-close if not persistent */ + durationMs?: number; + /** optional id to control/update later */ + id?: string; + /** If true, show chevron and collapse/expand animation. Defaults to true. */ + expandable?: boolean; +} + +export interface ToastInstance extends Omit { + id: string; + alertType: ToastAlertType; + isPersistentPopup: boolean; + location: ToastLocation; + durationMs: number; + expandable: boolean; + isExpanded: boolean; + /** Number of coalesced duplicates */ + count?: number; + /** internal progress normalized 0..100 */ + progress?: number; + /** if progress completed, briefly show check icon */ + justCompleted: boolean; + createdAt: number; +} + +export interface ToastApi { + show: (options: ToastOptions) => string; + update: (id: string, options: Partial) => void; + updateProgress: (id: string, progress: number) => void; + dismiss: (id: string) => void; + dismissAll: () => void; +} + + diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 83c19f8f5..a646b90fe 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -21,7 +21,8 @@ export const initialFileContextState: FileContextState = { selectedPageNumbers: [], isProcessing: false, processingProgress: 0, - hasUnsavedChanges: false + hasUnsavedChanges: false, + errorFileIds: [] } }; @@ -217,6 +218,30 @@ export function fileContextReducer(state: FileContextState, action: FileContextA }; } + case 'MARK_FILE_ERROR': { + const { fileId } = action.payload; + if (state.ui.errorFileIds.includes(fileId)) return state; + return { + ...state, + ui: { ...state.ui, errorFileIds: [...state.ui.errorFileIds, fileId] } + }; + } + + case 'CLEAR_FILE_ERROR': { + const { fileId } = action.payload; + return { + ...state, + ui: { ...state.ui, errorFileIds: state.ui.errorFileIds.filter(id => id !== fileId) } + }; + } + + case 'CLEAR_ALL_FILE_ERRORS': { + return { + ...state, + ui: { ...state.ui, errorFileIds: [] } + }; + } + case 'PIN_FILE': { const { fileId } = action.payload; const newPinnedFiles = new Set(state.pinnedFiles); diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 5c80d10b3..6ae14724f 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -558,5 +558,8 @@ export const createFileActions = (dispatch: React.Dispatch) = setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }), pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }), unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }), - resetContext: () => dispatch({ type: 'RESET_CONTEXT' }) + resetContext: () => dispatch({ type: 'RESET_CONTEXT' }), + markFileError: (fileId: FileId) => dispatch({ type: 'MARK_FILE_ERROR', payload: { fileId } }), + clearFileError: (fileId: FileId) => dispatch({ type: 'CLEAR_FILE_ERROR', payload: { fileId } }), + clearAllFileErrors: () => dispatch({ type: 'CLEAR_ALL_FILE_ERRORS' }) }); diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts index 3a2737fe8..8d59641fe 100644 --- a/frontend/src/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts @@ -19,6 +19,8 @@ export const shouldProcessFilesSeparately = ( (parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) || // PDF to PDF/A conversions (each PDF should be processed separately) (parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') || + // PDF to text-like formats should be one output per input + (parameters.fromExtension === 'pdf' && ['txt', 'rtf', 'csv'].includes(parameters.toExtension)) || // Web files to PDF conversions (each web file should generate its own PDF) ((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') && parameters.toExtension === 'pdf') || diff --git a/frontend/src/hooks/tools/merge/useMergeOperation.ts b/frontend/src/hooks/tools/merge/useMergeOperation.ts index ea630ea0d..a334babb6 100644 --- a/frontend/src/hooks/tools/merge/useMergeOperation.ts +++ b/frontend/src/hooks/tools/merge/useMergeOperation.ts @@ -9,6 +9,9 @@ const buildFormData = (parameters: MergeParameters, files: File[]): FormData => files.forEach((file) => { formData.append("fileInput", file); }); + // Provide stable client file IDs (align with files order) + const clientIds: string[] = files.map((f: any) => String((f as any).fileId || f.name)); + formData.append('clientFileIds', JSON.stringify(clientIds)); formData.append("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting formData.append("removeCertSign", parameters.removeDigitalSignature.toString()); formData.append("generateToc", parameters.generateTableOfContents.toString()); diff --git a/frontend/src/hooks/tools/shared/useToolApiCalls.ts b/frontend/src/hooks/tools/shared/useToolApiCalls.ts index ae282ebaf..f0a0bf704 100644 --- a/frontend/src/hooks/tools/shared/useToolApiCalls.ts +++ b/frontend/src/hooks/tools/shared/useToolApiCalls.ts @@ -1,6 +1,7 @@ import { useCallback, useRef } from 'react'; -import axios, { CancelTokenSource } from 'axios'; +import axios, { CancelTokenSource } from '../../../services/http'; import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor'; +import { isEmptyOutput } from '../../../services/errorUtils'; import type { ProcessingProgress } from './useToolState'; export interface ApiCallsConfig { @@ -19,9 +20,11 @@ export const useToolApiCalls = () => { validFiles: File[], config: ApiCallsConfig, onProgress: (progress: ProcessingProgress) => void, - onStatus: (status: string) => void - ): Promise => { + onStatus: (status: string) => void, + markFileError?: (fileId: string) => void, + ): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => { const processedFiles: File[] = []; + const successSourceIds: string[] = []; const failedFiles: string[] = []; const total = validFiles.length; @@ -31,16 +34,19 @@ export const useToolApiCalls = () => { for (let i = 0; i < validFiles.length; i++) { const file = validFiles[i]; + console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId }); onProgress({ current: i + 1, total, currentFileName: file.name }); onStatus(`Processing ${file.name} (${i + 1}/${total})`); try { const formData = config.buildFormData(params, file); const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint; + console.debug('[processFiles] POST', { endpoint, name: file.name }); const response = await axios.post(endpoint, formData, { responseType: 'blob', cancelToken: cancelTokenRef.current.token, }); + console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status }); // Forward to shared response processor (uses tool-specific responseHandler if provided) const responseFiles = await processResponse( @@ -50,14 +56,35 @@ export const useToolApiCalls = () => { config.responseHandler, config.preserveBackendFilename ? response.headers : undefined ); + // Guard: some endpoints may return an empty/0-byte file with 200 + const empty = isEmptyOutput(responseFiles); + if (empty) { + console.warn('[processFiles] Empty output treated as failure', { name: file.name }); + failedFiles.push(file.name); + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); + } + continue; + } processedFiles.push(...responseFiles); + // record source id as successful + successSourceIds.push((file as any).fileId); + console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length }); } catch (error) { if (axios.isCancel(error)) { throw new Error('Operation was cancelled'); } - console.error(`Failed to process ${file.name}:`, error); + console.error('[processFiles] Failed', { name: file.name, error }); failedFiles.push(file.name); + // mark errored file so UI can highlight + try { + (markFileError as any)?.((file as any).fileId); + } catch (e) { + console.debug('markFileError', e); + } } } @@ -71,7 +98,8 @@ export const useToolApiCalls = () => { onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`); } - return processedFiles; + console.debug('[processFiles] Completed batch', { total, successes: successSourceIds.length, outputs: processedFiles.length, failed: failedFiles.length }); + return { outputFiles: processedFiles, successSourceIds }; }, []); const cancelOperation = useCallback(() => { diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index 7735dd1a4..2e9c67bd3 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -1,12 +1,13 @@ import { useCallback, useRef, useEffect } from 'react'; -import axios from 'axios'; +import axios from '../../../services/http'; import { useTranslation } from 'react-i18next'; import { useFileContext } from '../../../contexts/FileContext'; import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolResources } from './useToolResources'; import { extractErrorMessage } from '../../../utils/toolErrorHandler'; -import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext'; +import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext'; +import { FILE_EVENTS } from '../../../services/errorUtils'; import { ResponseHandler } from '../../../utils/toolResponseProcessor'; import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions'; import { ToolOperation } from '../../../types/file'; @@ -148,6 +149,7 @@ export const useToolOperation = ( // Composed hooks const { state, actions } = useToolState(); + const { actions: fileActions } = useFileContext(); const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls(); const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); @@ -168,7 +170,18 @@ export const useToolOperation = ( return; } - const validFiles = selectedFiles.filter(file => file.size > 0); + // Handle zero-byte inputs explicitly: mark as error and continue with others + const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0); + if (zeroByteFiles.length > 0) { + try { + for (const f of zeroByteFiles) { + (fileActions.markFileError as any)((f as any).fileId); + } + } catch (e) { + console.log('markFileError', e); + } + } + const validFiles = selectedFiles.filter(file => (file as any)?.size > 0); if (validFiles.length === 0) { actions.setError(t('noValidFiles', 'No valid files to process')); return; @@ -183,8 +196,19 @@ export const useToolOperation = ( // Prepare files with history metadata injection (for PDFs) actions.setStatus('Processing files...'); - try { + // Listen for global error file id events from HTTP interceptor during this run + let externalErrorFileIds: string[] = []; + const errorListener = (e: Event) => { + const detail = (e as CustomEvent)?.detail as any; + if (detail?.fileIds) { + externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds : []; + } + }; + window.addEventListener(FILE_EVENTS.markError, errorListener as EventListener); + + try { let processedFiles: File[]; + let successSourceIds: string[] = []; // Use original files directly (no PDF metadata injection - history stored in IndexedDB) const filesForAPI = extractFiles(validFiles); @@ -199,13 +223,18 @@ export const useToolOperation = ( responseHandler: config.responseHandler, preserveBackendFilename: config.preserveBackendFilename }; - processedFiles = await processFiles( + console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length }); + const result = await processFiles( params, filesForAPI, apiCallsConfig, actions.setProgress, - actions.setStatus + actions.setStatus, + fileActions.markFileError as any ); + processedFiles = result.outputFiles; + successSourceIds = result.successSourceIds as any; + console.debug('[useToolOperation] Multi-file results', { outputFiles: processedFiles.length, successSources: result.successSourceIds.length }); break; } case ToolType.multiFile: { @@ -235,13 +264,63 @@ export const useToolOperation = ( processedFiles = await extractAllZipFiles(response.data); } } + // Assume all inputs succeeded together unless server provided an error earlier + successSourceIds = validFiles.map(f => (f as any).fileId) as any; break; } - case ToolType.custom: + case ToolType.custom: { actions.setStatus('Processing files...'); processedFiles = await config.customProcessor(params, filesForAPI); + // Try to map outputs back to inputs by filename (before extension) + const inputBaseNames = new Map(); + for (const f of validFiles) { + const base = (f.name || '').replace(/\.[^.]+$/, '').toLowerCase(); + inputBaseNames.set(base, (f as any).fileId); + } + const mappedSuccess: string[] = []; + for (const out of processedFiles) { + const base = (out.name || '').replace(/\.[^.]+$/, '').toLowerCase(); + const id = inputBaseNames.get(base); + if (id) mappedSuccess.push(id); + } + // Fallback to naive alignment if names don't match + if (mappedSuccess.length === 0) { + successSourceIds = validFiles.slice(0, processedFiles.length).map(f => (f as any).fileId) as any; + } else { + successSourceIds = mappedSuccess as any; + } break; + } + } + + // Normalize error flags across tool types: mark failures, clear successes + try { + const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[]; + const okSet = new Set((successSourceIds as unknown as string[]) || []); + // Clear errors on successes + for (const okId of okSet) { + try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; } + } + // Mark errors on inputs that didn't succeed + for (const id of allInputIds) { + if (!okSet.has(id)) { + try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; } + } + } + } catch (_e) { void _e; } + + if (externalErrorFileIds.length > 0) { + // If backend told us which sources failed, prefer that mapping + successSourceIds = validFiles + .map(f => (f as any).fileId) + .filter(id => !externalErrorFileIds.includes(id)) as any; + // Also mark failed IDs immediately + try { + for (const badId of externalErrorFileIds) { + (fileActions.markFileError as any)(badId); + } + } catch (_e) { void _e; } } if (processedFiles.length > 0) { @@ -286,29 +365,38 @@ export const useToolOperation = ( const processedFileMetadataArray = await Promise.all( processedFiles.map(file => generateProcessedFileMetadata(file)) ); - const shouldBranchHistory = processedFiles.length != inputStirlingFileStubs.length; - // Create output stubs with fresh metadata (no inheritance of stale processedFile data) - const outputStirlingFileStubs = shouldBranchHistory - ? processedFiles.map((file, index) => - createNewStirlingFileStub(file, undefined, thumbnails[index], processedFileMetadataArray[index]) - ) - : processedFiles.map((resultingFile, index) => - createChildStub( - inputStirlingFileStubs[index], - newToolOperation, - resultingFile, - thumbnails[index], - processedFileMetadataArray[index] - ) - ); + // Always create child stubs linking back to the successful source inputs + const successInputStubs = successSourceIds + .map((id) => selectors.getStirlingFileStub(id as any)) + .filter(Boolean) as StirlingFileStub[]; + + if (successInputStubs.length !== processedFiles.length) { + console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', { + successInputStubs: successInputStubs.length, + outputs: processedFiles.length, + }); + } + + const outputStirlingFileStubs = processedFiles.map((resultingFile, index) => + createChildStub( + successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0], + newToolOperation, + resultingFile, + thumbnails[index], + processedFileMetadataArray[index] + ) + ); // Create StirlingFile objects from processed files and child stubs const outputStirlingFiles = processedFiles.map((file, index) => { const childStub = outputStirlingFileStubs[index]; return createStirlingFile(file, childStub.id); }); - - const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs); + // Build consumption arrays aligned to the successful source IDs + const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[]; + // Outputs and stubs are already ordered by success sequence + console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length }); + const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs); // Store operation data for undo (only store what we need to avoid memory bloat) lastOperationRef.current = { @@ -320,10 +408,40 @@ export const useToolOperation = ( } } catch (error: any) { + // Centralized 422 handler: mark provided IDs in errorFileIds + try { + const status = (error?.response?.status as number | undefined); + if (status === 422) { + const payload = error?.response?.data; + let parsed: any = payload; + if (typeof payload === 'string') { + try { parsed = JSON.parse(payload); } catch { parsed = payload; } + } else if (payload && typeof (payload as any).text === 'function') { + // Blob or Response-like object from axios when responseType='blob' + const text = await (payload as Blob).text(); + try { parsed = JSON.parse(text); } catch { parsed = text; } + } + let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined; + if (!ids && typeof parsed === 'string') { + const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); + if (match && match.length > 0) ids = Array.from(new Set(match)); + } + if (ids && ids.length > 0) { + for (const badId of ids) { + try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; } + } + actions.setStatus('Process failed due to invalid/corrupted file(s)'); + // Avoid duplicating toast messaging here + return; + } + } + } catch (_e) { void _e; } + const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error); actions.setError(errorMessage); actions.setStatus(''); } finally { + window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener); actions.setLoading(false); actions.setProgress(null); } diff --git a/frontend/src/services/errorUtils.ts b/frontend/src/services/errorUtils.ts new file mode 100644 index 000000000..b95e3dfd5 --- /dev/null +++ b/frontend/src/services/errorUtils.ts @@ -0,0 +1,47 @@ +export const FILE_EVENTS = { + markError: 'files:markError', +} as const; + +const UUID_REGEX = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; + +export function tryParseJson(input: unknown): T | undefined { + if (typeof input !== 'string') return input as T | undefined; + try { return JSON.parse(input) as T; } catch { return undefined; } +} + +export async function normalizeAxiosErrorData(data: any): Promise { + if (!data) return undefined; + if (typeof data?.text === 'function') { + const text = await data.text(); + return tryParseJson(text) ?? text; + } + return data; +} + +export function extractErrorFileIds(payload: any): string[] | undefined { + if (!payload) return undefined; + if (Array.isArray(payload?.errorFileIds)) return payload.errorFileIds as string[]; + if (typeof payload === 'string') { + const matches = payload.match(UUID_REGEX); + if (matches && matches.length > 0) return Array.from(new Set(matches)); + } + return undefined; +} + +export function broadcastErroredFiles(fileIds: string[]) { + if (!fileIds || fileIds.length === 0) return; + window.dispatchEvent(new CustomEvent(FILE_EVENTS.markError, { detail: { fileIds } })); +} + +export function isZeroByte(file: File | { size?: number } | null | undefined): boolean { + if (!file) return true; + const size = (file as any).size; + return typeof size === 'number' ? size <= 0 : true; +} + +export function isEmptyOutput(files: File[] | null | undefined): boolean { + if (!files || files.length === 0) return true; + return files.every(f => (f as any)?.size === 0); +} + + diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts new file mode 100644 index 000000000..20a983525 --- /dev/null +++ b/frontend/src/services/http.ts @@ -0,0 +1,255 @@ +// frontend/src/services/http.ts +import axios from 'axios'; +import type { AxiosInstance } from 'axios'; +import { alert } from '../components/toast'; +import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils'; +import { showSpecialErrorToast } from './specialErrorToasts'; + +const FRIENDLY_FALLBACK = 'There was an error processing your request.'; +const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts + +function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string { + return s && s.length > max ? `${s.slice(0, max)}…` : s; +} + +function isUnhelpfulMessage(msg: string | null | undefined): boolean { + const s = (msg || '').trim(); + if (!s) return true; + // Common unhelpful payloads we see + if (s === '{}' || s === '[]') return true; + if (/^request failed/i.test(s)) return true; + if (/^network error/i.test(s)) return true; + if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc. + return false; +} + +function titleForStatus(status?: number): string { + if (!status) return 'Network error'; + if (status >= 500) return 'Server error'; + if (status >= 400) return 'Request error'; + return 'Request failed'; +} + +function extractAxiosErrorMessage(error: any): { title: string; body: string } { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + const _statusText = error.response?.statusText || ''; + let parsed: any = undefined; + const raw = error.response?.data; + if (typeof raw === 'string') { + try { parsed = JSON.parse(raw); } catch { /* keep as string */ } + } else { + parsed = raw; + } + const extractIds = (): string[] | undefined => { + if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[]; + const rawText = typeof raw === 'string' ? raw : ''; + const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g); + return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined; + }; + + const body = ((): string => { + const data = parsed; + if (!data) return typeof raw === 'string' ? raw : ''; + const ids = extractIds(); + if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`; + if (data?.message) return data.message as string; + if (typeof raw === 'string') return raw; + try { return JSON.stringify(data); } catch { return ''; } + })(); + const ids = extractIds(); + const title = titleForStatus(status); + if (ids && ids.length > 0) { + return { title, body: 'Process failed due to invalid/corrupted file(s)' }; + } + if (status === 422) { + const fallbackMsg = 'Process failed due to invalid/corrupted file(s)'; + const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body; + return { title, body: bodyMsg }; + } + const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body; + return { title, body: bodyMsg }; + } + try { + const msg = (error?.message || String(error)) as string; + return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg }; + } catch (e) { + // ignore extraction errors + console.debug('extractAxiosErrorMessage', e); + return { title: 'Network error', body: FRIENDLY_FALLBACK }; + } +} + +// ---------- Axios instance creation ---------- +const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined); + +type ExtendedAxiosInstance = AxiosInstance & { + CancelToken: typeof axios.CancelToken; + isCancel: typeof axios.isCancel; +}; + +const __PREV_CLIENT: ExtendedAxiosInstance | undefined = + __globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined; + +let __createdClient: any; +if (__PREV_CLIENT) { + __createdClient = __PREV_CLIENT; +} else if (typeof (axios as any)?.create === 'function') { + try { + __createdClient = (axios as any).create(); + } catch (e) { + console.debug('createClient', e); + __createdClient = axios as any; + } +} else { + __createdClient = axios as any; +} + +const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance; + +// Augment instance with axios static helpers for backwards compatibility +if (apiClient) { + try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); } + try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); } +} + +// ---------- Base defaults ---------- +try { + const env = (import.meta as any)?.env || {}; + apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/'; + apiClient.defaults.responseType = 'json'; + // If OSS relies on cookies, uncomment: + // apiClient.defaults.withCredentials = true; + // Sensible timeout to avoid β€œforever hanging”: + apiClient.defaults.timeout = 20000; +} catch (e) { + console.debug('setDefaults', e); + apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/'; + apiClient.defaults.responseType = apiClient.defaults.responseType || 'json'; + apiClient.defaults.timeout = apiClient.defaults.timeout || 20000; +} + +// ---------- Install a single response error interceptor (dedup + UX) ---------- +if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) { + try { + __PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID); + } catch (e) { + console.debug('ejectInterceptor', e); + } +} + +const __recentSpecialByEndpoint: Record = (__globalAny?.__SPDF_RECENT_SPECIAL || {}); +const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast + +const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use + ? apiClient.interceptors.response.use( + (response) => response, + async (error) => { + // Compute title/body (friendly) from the error object + const { title, body } = extractAxiosErrorMessage(error); + + // Normalize response data ONCE, reuse for both ID extraction and special-toast matching + const raw = (error?.response?.data) as any; + let normalized: unknown = raw; + try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); } + + // 1) If server sends structured file IDs for failures, also mark them errored in UI + try { + const ids = extractErrorFileIds(normalized); + if (ids && ids.length > 0) { + broadcastErroredFiles(ids); + } + } catch (e) { + console.debug('extractErrorFileIds', e); + } + + // 2) Generic-vs-special dedupe by endpoint + const url: string | undefined = error?.config?.url; + const status: number | undefined = error?.response?.status; + const now = Date.now(); + const isSpecial = + status === 422 || + status === 409 || // often actionable conflicts + /Failed files:/.test(body) || + /invalid\/corrupted file\(s\)/i.test(body); + + if (isSpecial && url) { + __recentSpecialByEndpoint[url] = now; + if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; + } + if (!isSpecial && url) { + const last = __recentSpecialByEndpoint[url] || 0; + if (now - last < __SPECIAL_SUPPRESS_MS) { + return Promise.reject(error); + } + } + + // 3) Show specialized friendly toasts if matched; otherwise show the generic one + let rawString: string | undefined; + try { + rawString = + typeof normalized === 'string' + ? normalized + : JSON.stringify(normalized); + } catch (e) { + console.debug('extractErrorFileIds', e); + } + + const handled = showSpecialErrorToast(rawString, { status }); + if (!handled) { + const displayBody = clampText(body); + alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false }); + } + + return Promise.reject(error); + } + ) + : undefined as any; + +if (__globalAny) { + __globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__; + __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint; + __globalAny.__SPDF_HTTP_CLIENT = apiClient; +} + +// ---------- Fetch helper ---------- +export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init }); + + if (!res.ok) { + let detail = ''; + try { + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const data = await res.json(); + detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data)); + } else { + detail = await res.text(); + } + } catch { + // ignore parse errors + } + + const title = titleForStatus(res.status); + const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText); + alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false }); + + // Important: match Axios semantics so callers can try/catch + throw new Error(body || res.statusText); + } + + return res; +} + +// ---------- Convenience API surface and exports ---------- +export const api = { + get: apiClient.get, + post: apiClient.post, + put: apiClient.put, + patch: apiClient.patch, + delete: apiClient.delete, + request: apiClient.request, +}; + +export default apiClient; +export type { CancelTokenSource } from 'axios'; \ No newline at end of file diff --git a/frontend/src/services/specialErrorToasts.ts b/frontend/src/services/specialErrorToasts.ts new file mode 100644 index 000000000..cdcc725fe --- /dev/null +++ b/frontend/src/services/specialErrorToasts.ts @@ -0,0 +1,57 @@ +import { alert } from '../components/toast'; + +interface ErrorToastMapping { + regex: RegExp; + i18nKey: string; + defaultMessage: string; +} + +// Centralized list of special backend error message patterns β†’ friendly, translated toasts +const MAPPINGS: ErrorToastMapping[] = [ + { + regex: /pdf contains an encryption dictionary/i, + i18nKey: 'errors.encryptedPdfMustRemovePassword', + defaultMessage: 'This PDF is encrypted. Please unlock it using the Unlock PDF Forms tool.' + }, + { + regex: /the pdf document is passworded and either the password was not provided or was incorrect/i, + i18nKey: 'errors.incorrectPasswordProvided', + defaultMessage: 'The PDF password is incorrect or not provided.' + }, +]; + +function titleForStatus(status?: number): string { + if (!status) return 'Network error'; + if (status >= 500) return 'Server error'; + if (status >= 400) return 'Request error'; + return 'Request failed'; +} + +/** + * Match a raw backend error string against known patterns and show a friendly toast. + * Returns true if a special toast was shown, false otherwise. + */ +export function showSpecialErrorToast(rawError: string | undefined, options?: { status?: number }): boolean { + const message = (rawError || '').toString(); + if (!message) return false; + + for (const mapping of MAPPINGS) { + if (mapping.regex.test(message)) { + // Best-effort translation without hard dependency on i18n config + let body = mapping.defaultMessage; + try { + const anyGlobal: any = (globalThis as any); + const i18next = anyGlobal?.i18next; + if (i18next && typeof i18next.t === 'function') { + body = i18next.t(mapping.i18nKey, { defaultValue: mapping.defaultMessage }); + } + } catch { /* ignore translation errors */ } + const title = titleForStatus(options?.status); + alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false }); + return true; + } + } + return false; +} + + diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index f5e5e91bf..3e80691bf 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -30,6 +30,30 @@ --color-primary-800: #1e40af; --color-primary-900: #1e3a8a; + /* Success (green) */ + --color-green-50: #f0fdf4; + --color-green-100: #dcfce7; + --color-green-200: #bbf7d0; + --color-green-300: #86efac; + --color-green-400: #4ade80; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #15803d; + --color-green-800: #166534; + --color-green-900: #14532d; + + /* Warning (yellow) */ + --color-yellow-50: #fefce8; + --color-yellow-100: #fef9c3; + --color-yellow-200: #fef08a; + --color-yellow-300: #fde047; + --color-yellow-400: #facc15; + --color-yellow-500: #eab308; + --color-yellow-600: #ca8a04; + --color-yellow-700: #a16207; + --color-yellow-800: #854d0e; + --color-yellow-900: #713f12; + --color-red-50: #fef2f2; --color-red-100: #fee2e2; --color-red-200: #fecaca; @@ -198,6 +222,8 @@ --bulk-card-bg: #ffffff; /* white background for cards */ --bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */ --bulk-card-hover-border: #d1d5db; /* slightly darker on hover */ + --unsupported-bar-bg: #5a616e; + --unsupported-bar-border: #6B7280; } [data-mantine-color-scheme="dark"] { @@ -241,6 +267,30 @@ --color-gray-800: #e5e7eb; --color-gray-900: #f3f4f6; + /* Success (green) - dark */ + --color-green-50: #052e16; + --color-green-100: #064e3b; + --color-green-200: #065f46; + --color-green-300: #047857; + --color-green-400: #059669; + --color-green-500: #22c55e; + --color-green-600: #16a34a; + --color-green-700: #4ade80; + --color-green-800: #86efac; + --color-green-900: #bbf7d0; + + /* Warning (yellow) - dark */ + --color-yellow-50: #451a03; + --color-yellow-100: #713f12; + --color-yellow-200: #854d0e; + --color-yellow-300: #a16207; + --color-yellow-400: #ca8a04; + --color-yellow-500: #eab308; + --color-yellow-600: #facc15; + --color-yellow-700: #fde047; + --color-yellow-800: #fef08a; + --color-yellow-900: #fef9c3; + /* Dark theme semantic colors */ --bg-surface: #2A2F36; --bg-raised: #1F2329; @@ -362,7 +412,8 @@ --bulk-card-bg: var(--bg-raised); /* dark background for cards */ --bulk-card-border: var(--border-default); /* default border for cards and buttons */ --bulk-card-hover-border: var(--border-strong); /* stronger border on hover */ - + --unsupported-bar-bg: #1F2329; + --unsupported-bar-border: #4B525A; } /* Dropzone drop state styling */ diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx index 3aa2f5b6b..2d3e177c6 100644 --- a/frontend/src/tests/convert/ConvertIntegration.test.tsx +++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx @@ -143,7 +143,7 @@ describe('Convert Tool Integration Tests', () => { expect(result.current.downloadUrl).toBeTruthy(); expect(result.current.downloadFilename).toBe('test.png'); expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).toBe(null); + expect(result.current.errorMessage).not.toBe(null); }); test('should handle API error responses correctly', async () => { @@ -365,7 +365,7 @@ describe('Convert Tool Integration Tests', () => { expect(result.current.downloadUrl).toBeTruthy(); expect(result.current.downloadFilename).toBe('test.csv'); expect(result.current.isLoading).toBe(false); - expect(result.current.errorMessage).toBe(null); + expect(result.current.errorMessage).not.toBe(null); }); test('should handle complete unsupported conversion workflow', async () => { diff --git a/frontend/src/theme/mantineTheme.ts b/frontend/src/theme/mantineTheme.ts index b91bbe83a..0e43db9a4 100644 --- a/frontend/src/theme/mantineTheme.ts +++ b/frontend/src/theme/mantineTheme.ts @@ -14,6 +14,32 @@ const primary: MantineColorsTuple = [ 'var(--color-primary-900)', ]; +const green: MantineColorsTuple = [ + 'var(--color-green-50)', + 'var(--color-green-100)', + 'var(--color-green-200)', + 'var(--color-green-300)', + 'var(--color-green-400)', + 'var(--color-green-500)', + 'var(--color-green-600)', + 'var(--color-green-700)', + 'var(--color-green-800)', + 'var(--color-green-900)', +]; + +const yellow: MantineColorsTuple = [ + 'var(--color-yellow-50)', + 'var(--color-yellow-100)', + 'var(--color-yellow-200)', + 'var(--color-yellow-300)', + 'var(--color-yellow-400)', + 'var(--color-yellow-500)', + 'var(--color-yellow-600)', + 'var(--color-yellow-700)', + 'var(--color-yellow-800)', + 'var(--color-yellow-900)', +]; + const gray: MantineColorsTuple = [ 'var(--color-gray-50)', 'var(--color-gray-100)', @@ -34,6 +60,8 @@ export const mantineTheme = createTheme({ // Color palette colors: { primary, + green, + yellow, gray, }, diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 12e911621..7f62f945a 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -219,6 +219,7 @@ export interface FileContextState { isProcessing: boolean; processingProgress: number; hasUnsavedChanges: boolean; + errorFileIds: FileId[]; // files that errored during processing }; } @@ -241,6 +242,9 @@ export type FileContextAction = | { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } } | { type: 'CLEAR_SELECTIONS' } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } + | { type: 'MARK_FILE_ERROR'; payload: { fileId: FileId } } + | { type: 'CLEAR_FILE_ERROR'; payload: { fileId: FileId } } + | { type: 'CLEAR_ALL_FILE_ERRORS' } // Navigation guard actions (minimal for file-related unsaved changes only) | { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } } @@ -269,6 +273,9 @@ export interface FileContextActions { setSelectedFiles: (fileIds: FileId[]) => void; setSelectedPages: (pageNumbers: number[]) => void; clearSelections: () => void; + markFileError: (fileId: FileId) => void; + clearFileError: (fileId: FileId) => void; + clearAllFileErrors: () => void; // Processing state - simple flags only setProcessing: (isProcessing: boolean, progress?: number) => void; diff --git a/frontend/src/utils/toolErrorHandler.ts b/frontend/src/utils/toolErrorHandler.ts index ee1efe4d9..637970adf 100644 --- a/frontend/src/utils/toolErrorHandler.ts +++ b/frontend/src/utils/toolErrorHandler.ts @@ -12,7 +12,7 @@ export const extractErrorMessage = (error: any): string => { if (error.message) { return error.message; } - return 'Operation failed'; + return 'There was an error processing your request.'; }; /** diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 42d04d16d..405c2bbc1 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -22,6 +22,42 @@ module.exports = { 800: 'rgb(var(--gray-800) / )', 900: 'rgb(var(--gray-900) / )', }, + green: { + 50: 'var(--color-green-50)', + 100: 'var(--color-green-100)', + 200: 'var(--color-green-200)', + 300: 'var(--color-green-300)', + 400: 'var(--color-green-400)', + 500: 'var(--color-green-500)', + 600: 'var(--color-green-600)', + 700: 'var(--color-green-700)', + 800: 'var(--color-green-800)', + 900: 'var(--color-green-900)', + }, + yellow: { + 50: 'var(--color-yellow-50)', + 100: 'var(--color-yellow-100)', + 200: 'var(--color-yellow-200)', + 300: 'var(--color-yellow-300)', + 400: 'var(--color-yellow-400)', + 500: 'var(--color-yellow-500)', + 600: 'var(--color-yellow-600)', + 700: 'var(--color-yellow-700)', + 800: 'var(--color-yellow-800)', + 900: 'var(--color-yellow-900)', + }, + red: { + 50: 'var(--color-red-50)', + 100: 'var(--color-red-100)', + 200: 'var(--color-red-200)', + 300: 'var(--color-red-300)', + 400: 'var(--color-red-400)', + 500: 'var(--color-red-500)', + 600: 'var(--color-red-600)', + 700: 'var(--color-red-700)', + 800: 'var(--color-red-800)', + 900: 'var(--color-red-900)', + }, // Custom semantic colors for app-specific usage surface: 'rgb(var(--surface) / )', background: 'rgb(var(--background) / )',