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..554db9ce7 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,47 @@ 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 + try (PDDocument ignored = pdfDocumentFactory.load(tempFile)) { + // 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/src/App.tsx b/frontend/src/App.tsx index d7d9560a3..767fa918a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,6 @@ import "./styles/cookieconsent.css"; import "./index.css"; import { RightRailProvider } from "./contexts/RightRailContext"; import { ViewerProvider } from "./contexts/ViewerContext"; -import ToastPlayground from "./components/toast/ToastPlayground"; // Import file ID debugging helpers (development only) import "./utils/fileIdSafety"; @@ -48,7 +47,6 @@ export default function App() { - {import.meta.env.DEV && } 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/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index a933ddf1c..ad3681dd7 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -13,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'; @@ -47,7 +48,9 @@ const FileEditorThumbnail = ({ 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); @@ -188,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 {} + } 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)} @@ -329,7 +343,10 @@ const FileEditorThumbnail = ({
{/* Preview area */} -
+
{file.thumbnailUrl && ( { if (currentView === 'fileEditor' || currentView === 'viewer') { setSelectedFiles([]); + // Clear any previous error flags when deselecting all + try { fileActions.clearAllFileErrors(); } catch {} 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 index 668c1df9e..481f6ff78 100644 --- a/frontend/src/components/toast/ToastContext.tsx +++ b/frontend/src/components/toast/ToastContext.tsx @@ -49,17 +49,30 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { 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: options.expandable !== false, - isExpanded: options.expandable === false ? true : false, + expandable: hasButton ? false : (options.expandable !== false), + isExpanded: hasButton ? true : (options.expandable === false ? 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; }); diff --git a/frontend/src/components/toast/ToastPlayground.tsx b/frontend/src/components/toast/ToastPlayground.tsx deleted file mode 100644 index 5d52fa7a8..000000000 --- a/frontend/src/components/toast/ToastPlayground.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; -import { alert, updateToastProgress, updateToast, dismissToast, dismissAllToasts } from './index'; - -function wait(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -export default function ToastPlayground() { - const runProgress = async () => { - const id = alert({ - alertType: 'neutral', - title: 'Downloading…', - body: 'Fetching data from server', - progressBarPercentage: 0, - isPersistentPopup: true, - location: 'bottom-right', - }); - for (let p = 0; p <= 100; p += 10) { - updateToastProgress(id, p); - // eslint-disable-next-line no-await-in-loop - await wait(250); - } - updateToast(id, { title: 'Download complete', body: 'File saved', isPersistentPopup: false, alertType: 'success' }); - setTimeout(() => dismissToast(id), 2000); - }; - - const withButtons = () => { - alert({ - alertType: 'warning', - title: 'Replace existing file?', - body: 'A file with the same name already exists.', - buttonText: 'Replace', - buttonCallback: () => alert({ alertType: 'success', title: 'Replaced', body: 'Your file has been replaced.' }), - isPersistentPopup: true, - location: 'top-right', - }); - }; - - const withCustomIcon = () => { - alert({ - alertType: 'neutral', - title: 'Custom icon', - body: 'This toast shows a custom SVG icon.', - icon: ( - - - - - - ), - isPersistentPopup: false, - location: 'top-left', - }); - }; - - const differentLocations = () => { - (['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).forEach((loc) => { - alert({ alertType: 'neutral', title: `Toast @ ${loc}`, body: 'Location test', location: loc }); - }); - }; - - const success = () => alert({ alertType: 'success', title: 'Success', body: 'Operation completed.' }); - const error = () => alert({ alertType: 'error', title: 'Error', body: 'Something went wrong.' }); - const warning = () => alert({ alertType: 'warning', title: 'Warning', body: 'Please check your inputs.' }); - const neutral = () => alert({ alertType: 'neutral', title: 'Information', body: 'Heads up!' }); - - const persistent = () => alert({ alertType: 'neutral', title: 'Persistent toast', body: 'Click Γ— to close.', isPersistentPopup: true }); - - return ( -
-
- - - - - - - - - - - - -
-
- ); -} - -function Button({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { - return ( - - ); -} - -function Divider() { - return
; -} - - 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 index a525abcc7..b0108bfe9 100644 --- a/frontend/src/components/toast/ToastRenderer.tsx +++ b/frontend/src/components/toast/ToastRenderer.tsx @@ -2,26 +2,25 @@ 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': { top: 16, left: 16, flexDirection: 'column' }, - 'top-right': { top: 16, right: 16, flexDirection: 'column' }, - 'bottom-left': { bottom: 16, left: 16, flexDirection: 'column-reverse' }, - 'bottom-right': { bottom: 16, right: 16, flexDirection: 'column-reverse' }, +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 getColors(t: ToastInstance) { - switch (t.alertType) { - case 'success': - return { bg: 'var(--color-green-100)', border: 'var(--color-green-400)', text: 'var(--text-primary)', bar: 'var(--color-green-500)' }; - case 'error': - return { bg: 'var(--color-red-100)', border: 'var(--color-red-400)', text: 'var(--text-primary)', bar: 'var(--color-red-500)' }; - case 'warning': - return { bg: 'var(--color-yellow-100)', border: 'var(--color-yellow-400)', text: 'var(--text-primary)', bar: 'var(--color-yellow-500)' }; - case 'neutral': - default: - return { bg: 'var(--bg-surface)', border: 'var(--border-default)', text: 'var(--text-primary)', bar: 'var(--color-gray-500)' }; - } +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 { @@ -51,42 +50,33 @@ export default function ToastRenderer() { return ( <> {(Object.keys(grouped) as ToastLocation[]).map((loc) => ( -
+
{grouped[loc].map(t => { - const colors = getColors(t); return (
{/* Top row: Icon + Title + Controls */} -
+
{/* Icon */} -
+
{t.icon ?? ( - + )}
- {/* Title */} -
{t.title}
+ {/* Title + count badge */} +
+ {t.title} + {typeof t.count === 'number' && t.count > 1 && ( + {t.count} + )} +
{/* Controls */} -
+
{t.expandable && ( @@ -115,53 +92,38 @@ export default function ToastRenderer() {
+ {/* Progress bar - always show when present */} + {typeof t.progress === 'number' && ( +
+
+
+ )} + + {/* Body content - only show when expanded */} {(t.isExpanded || !t.expandable) && ( -
- {t.body} - {t.buttonText && t.buttonCallback && ( +
+ {t.body} +
+ )} + + {/* Button - always show when present, positioned below body */} + {t.buttonText && t.buttonCallback && ( +
- )} - {typeof t.progress === 'number' && ( -
-
-
- )}
)}
diff --git a/frontend/src/components/toast/types.ts b/frontend/src/components/toast/types.ts index a3071e80f..aeb0c79a5 100644 --- a/frontend/src/components/toast/types.ts +++ b/frontend/src/components/toast/types.ts @@ -30,6 +30,8 @@ export interface ToastInstance extends Omit 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 e71771573..6c83e11e2 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 '../../../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,27 @@ 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 {} + 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 {} } } @@ -71,7 +90,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 896081fbc..ee5bb471a 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -7,6 +7,7 @@ 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 { 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,16 @@ 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 {} + } + 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 +194,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 +221,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 +262,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 {} + } + // Mark errors on inputs that didn't succeed + for (const id of allInputIds) { + if (!okSet.has(id)) { + try { (fileActions.markFileError as any)(id); } catch {} + } + } + } catch {} + + 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 {} } if (processedFiles.length > 0) { @@ -286,29 +363,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 +406,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 {} + } + actions.setStatus('Some files could not be processed'); + // Avoid duplicating toast messaging here + return; + } + } + } catch {} + 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 index fd1812932..7e3a0568d 100644 --- a/frontend/src/services/http.ts +++ b/frontend/src/services/http.ts @@ -1,38 +1,79 @@ import axios from 'axios'; import { alert } from '../components/toast'; +import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils'; -function extractAxiosErrorMessage(error: any): string { +const FRIENDLY_FALLBACK = 'There was an error processing your request.'; + +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 || 'Request Error'; + 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 = error.response?.data as any; - if (!data) return ''; - if (typeof data === 'string') return data; + 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 ''; } })(); - return `${status ?? ''} ${statusText}${body ? `: ${body}` : ''}`.trim(); + const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body; + const title = titleForStatus(status); + return { title, body: bodyMsg }; } try { - return (error?.message || String(error)) as string; + const msg = (error?.message || String(error)) as string; + return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg }; } catch { - return 'Unknown network error'; + return { title: 'Network error', body: FRIENDLY_FALLBACK }; } } // Install Axios response error interceptor axios.interceptors.response.use( (response) => response, - (error) => { - const msg = extractAxiosErrorMessage(error); - alert({ - alertType: 'error', - title: 'Request failed', - body: msg, - expandable: true, - isPersistentPopup: false, - }); + async (error) => { + const { title, body } = extractAxiosErrorMessage(error); + // If server sends structured file IDs for failures, also mark them errored in UI + try { + const raw = (error?.response?.data) as any; + const data = await normalizeAxiosErrorData(raw); + const ids = extractErrorFileIds(data); + if (ids && ids.length > 0) broadcastErroredFiles(ids); + } catch {} + alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false }); return Promise.reject(error); } ); @@ -52,13 +93,9 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr } catch { // ignore parse errors } - alert({ - alertType: 'error', - title: `Request failed (${res.status})`, - body: detail || res.statusText, - expandable: true, - isPersistentPopup: false, - }); + const title = titleForStatus(res.status); + const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText); + alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false }); } return res; } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 4d416cf09..3e80691bf 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -222,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"] { @@ -410,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/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;