mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-26 17:52:59 +02:00
show errors on files in workbench
This commit is contained in:
parent
a5777a0059
commit
428f9eadbe
@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
|
|||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.attribute.BasicFileAttributes;
|
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.PDAcroForm;
|
||||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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
|
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||||
// Create the document outline
|
// Create the document outline
|
||||||
@ -177,15 +206,47 @@ public class MergeController {
|
|||||||
|
|
||||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||||
long totalSize = 0;
|
long totalSize = 0;
|
||||||
for (MultipartFile multipartFile : files) {
|
List<Integer> invalidIndexes = new ArrayList<>();
|
||||||
|
for (int index = 0; index < files.length; index++) {
|
||||||
|
MultipartFile multipartFile = files[index];
|
||||||
totalSize += multipartFile.getSize();
|
totalSize += multipartFile.getSize();
|
||||||
File tempFile =
|
File tempFile =
|
||||||
GeneralUtils.convertMultipartFileToFile(
|
GeneralUtils.convertMultipartFileToFile(
|
||||||
multipartFile); // Convert MultipartFile to File
|
multipartFile); // Convert MultipartFile to File
|
||||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
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
|
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<String> 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();
|
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
|
||||||
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
|
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
|
||||||
|
|
||||||
|
@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles {
|
|||||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||||
defaultValue = "false")
|
defaultValue = "false")
|
||||||
private boolean generateToc = 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;
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,6 @@ import "./styles/cookieconsent.css";
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
import { ViewerProvider } from "./contexts/ViewerContext";
|
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||||
import ToastPlayground from "./components/toast/ToastPlayground";
|
|
||||||
|
|
||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "./utils/fileIdSafety";
|
import "./utils/fileIdSafety";
|
||||||
@ -48,7 +47,6 @@ export default function App() {
|
|||||||
<ViewerProvider>
|
<ViewerProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
{import.meta.env.DEV && <ToastPlayground />}
|
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</ViewerProvider>
|
</ViewerProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
@ -56,6 +56,20 @@
|
|||||||
border-bottom: 1px solid var(--header-selected-bg);
|
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 */
|
/* Selected border color in light mode */
|
||||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||||
outline-color: var(--card-selected-border);
|
outline-color: var(--card-selected-border);
|
||||||
@ -80,6 +94,7 @@
|
|||||||
|
|
||||||
.kebab {
|
.kebab {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
color: #FFFFFF !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Menu dropdown */
|
/* Menu dropdown */
|
||||||
@ -217,6 +232,22 @@
|
|||||||
height: 20px;
|
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 */
|
/* Animations */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%, 100% {
|
||||||
|
@ -13,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext';
|
|||||||
|
|
||||||
import styles from './FileEditor.module.css';
|
import styles from './FileEditor.module.css';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
|
import { useFileState } from '../../contexts/file/fileHooks';
|
||||||
import { FileId } from '../../types/file';
|
import { FileId } from '../../types/file';
|
||||||
import { formatFileSize } from '../../utils/fileUtils';
|
import { formatFileSize } from '../../utils/fileUtils';
|
||||||
import ToolChain from '../shared/ToolChain';
|
import ToolChain from '../shared/ToolChain';
|
||||||
@ -47,7 +48,9 @@ const FileEditorThumbnail = ({
|
|||||||
isSupported = true,
|
isSupported = true,
|
||||||
}: FileEditorThumbnailProps) => {
|
}: FileEditorThumbnailProps) => {
|
||||||
const { t } = useTranslation();
|
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 ----
|
// ---- Drag state ----
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@ -188,9 +191,20 @@ const FileEditorThumbnail = ({
|
|||||||
// ---- Card interactions ----
|
// ---- Card interactions ----
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (!isSupported) return;
|
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);
|
onToggleFile(file.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Style helpers ----
|
||||||
|
const getHeaderClassName = () => {
|
||||||
|
if (hasError) return styles.headerError;
|
||||||
|
if (!isSupported) return styles.headerUnsupported;
|
||||||
|
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -200,10 +214,7 @@ const FileEditorThumbnail = ({
|
|||||||
data-selected={isSelected}
|
data-selected={isSelected}
|
||||||
data-supported={isSupported}
|
data-supported={isSupported}
|
||||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||||
style={{
|
style={{opacity: isDragging ? 0.9 : 1}}
|
||||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
|
||||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="listitem"
|
role="listitem"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
@ -211,13 +222,16 @@ const FileEditorThumbnail = ({
|
|||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div
|
||||||
className={`${styles.header} ${
|
className={`${styles.header} ${getHeaderClassName()}`}
|
||||||
isSelected ? styles.headerSelected : styles.headerResting
|
data-has-error={hasError}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Logo/checkbox area */}
|
{/* Logo/checkbox area */}
|
||||||
<div className={styles.logoMark}>
|
<div className={styles.logoMark}>
|
||||||
{isSupported ? (
|
{hasError ? (
|
||||||
|
<div className={styles.errorPill}>
|
||||||
|
<span>{t('error._value', 'Error')}</span>
|
||||||
|
</div>
|
||||||
|
) : isSupported ? (
|
||||||
<CheckboxIndicator
|
<CheckboxIndicator
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => onToggleFile(file.id)}
|
onChange={() => onToggleFile(file.id)}
|
||||||
@ -329,7 +343,10 @@ const FileEditorThumbnail = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview area */}
|
{/* Preview area */}
|
||||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
<div
|
||||||
|
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||||
|
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
|
||||||
|
>
|
||||||
<div className={styles.previewPaper}>
|
<div className={styles.previewPaper}>
|
||||||
{file.thumbnailUrl && (
|
{file.thumbnailUrl && (
|
||||||
<img
|
<img
|
||||||
|
@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
|
|||||||
import './rightRail/RightRail.css';
|
import './rightRail/RightRail.css';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
import { useRightRail } from '../../contexts/RightRailContext';
|
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 { useNavigationState } from '../../contexts/NavigationContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@ -39,6 +39,7 @@ export default function RightRail() {
|
|||||||
|
|
||||||
// File state and selection
|
// File state and selection
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
|
const { actions: fileActions } = useFileContext();
|
||||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||||
const { removeFiles } = useFileManagement();
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
@ -70,6 +71,8 @@ export default function RightRail() {
|
|||||||
// Select all file IDs
|
// Select all file IDs
|
||||||
const allIds = state.files.ids;
|
const allIds = state.files.ids;
|
||||||
setSelectedFiles(allIds);
|
setSelectedFiles(allIds);
|
||||||
|
// Clear any previous error flags when selecting all
|
||||||
|
try { fileActions.clearAllFileErrors(); } catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,6 +85,8 @@ export default function RightRail() {
|
|||||||
const handleDeselectAll = useCallback(() => {
|
const handleDeselectAll = useCallback(() => {
|
||||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
|
// Clear any previous error flags when deselecting all
|
||||||
|
try { fileActions.clearAllFileErrors(); } catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentView === 'pageEditor') {
|
if (currentView === 'pageEditor') {
|
||||||
|
309
frontend/src/components/toast/Toast.README.md
Normal file
309
frontend/src/components/toast/Toast.README.md
Normal file
@ -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: (
|
||||||
|
<div>
|
||||||
|
<p>File saved to Downloads folder</p>
|
||||||
|
<button onClick={() => openFolder()}>Open folder</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
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<ToastOptions>)`
|
||||||
|
|
||||||
|
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: <LocalIcon icon="star" />
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -49,17 +49,30 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const show = useCallback<ToastApi['show']>((options) => {
|
const show = useCallback<ToastApi['show']>((options) => {
|
||||||
const id = options.id || generateId();
|
const id = options.id || generateId();
|
||||||
|
const hasButton = !!(options.buttonText && options.buttonCallback);
|
||||||
const merged: ToastInstance = {
|
const merged: ToastInstance = {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
...options,
|
...options,
|
||||||
id,
|
id,
|
||||||
progress: normalizeProgress(options.progressBarPercentage),
|
progress: normalizeProgress(options.progressBarPercentage),
|
||||||
justCompleted: false,
|
justCompleted: false,
|
||||||
expandable: options.expandable !== false,
|
expandable: hasButton ? false : (options.expandable !== false),
|
||||||
isExpanded: options.expandable === false ? true : false,
|
isExpanded: hasButton ? true : (options.expandable === false ? true : false),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
} as ToastInstance;
|
} as ToastInstance;
|
||||||
setToasts(prev => {
|
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];
|
const next = [...prev.filter(t => t.id !== id), merged];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
@ -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: (
|
|
||||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="2" />
|
|
||||||
<path d="M10 5v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
||||||
<circle cx="10" cy="14.5" r="1" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
zIndex: 1150,
|
|
||||||
background: 'linear-gradient(to top, rgba(0,0,0,0.08), transparent)',
|
|
||||||
padding: '12px 8px',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
gap: 8,
|
|
||||||
overflowX: 'auto',
|
|
||||||
padding: '8px',
|
|
||||||
borderRadius: 12,
|
|
||||||
background: 'var(--bg-surface)',
|
|
||||||
border: '1px solid var(--border-default)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button onClick={success}>Success</Button>
|
|
||||||
<Button onClick={error}>Error</Button>
|
|
||||||
<Button onClick={warning}>Warning</Button>
|
|
||||||
<Button onClick={neutral}>Neutral</Button>
|
|
||||||
<Divider />
|
|
||||||
<Button onClick={withButtons}>With button</Button>
|
|
||||||
<Button onClick={withCustomIcon}>Custom icon</Button>
|
|
||||||
<Button onClick={differentLocations}>All locations</Button>
|
|
||||||
<Button onClick={runProgress}>Progress demo</Button>
|
|
||||||
<Button onClick={persistent}>Persistent</Button>
|
|
||||||
<Divider />
|
|
||||||
<Button onClick={() => dismissAllToasts()}>Dismiss all</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Button({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: 10,
|
|
||||||
border: '1px solid var(--border-default)',
|
|
||||||
background: 'var(--bg-surface)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Divider() {
|
|
||||||
return <div style={{ width: 1, background: 'var(--border-default)' }} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
209
frontend/src/components/toast/ToastRenderer.css
Normal file
209
frontend/src/components/toast/ToastRenderer.css
Normal file
@ -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);
|
||||||
|
}
|
@ -2,26 +2,25 @@ import React from 'react';
|
|||||||
import { useToast } from './ToastContext';
|
import { useToast } from './ToastContext';
|
||||||
import { ToastInstance, ToastLocation } from './types';
|
import { ToastInstance, ToastLocation } from './types';
|
||||||
import { LocalIcon } from '../shared/LocalIcon';
|
import { LocalIcon } from '../shared/LocalIcon';
|
||||||
|
import './ToastRenderer.css';
|
||||||
|
|
||||||
const locationToClass: Record<ToastLocation, React.CSSProperties> = {
|
const locationToClass: Record<ToastLocation, string> = {
|
||||||
'top-left': { top: 16, left: 16, flexDirection: 'column' },
|
'top-left': 'toast-container--top-left',
|
||||||
'top-right': { top: 16, right: 16, flexDirection: 'column' },
|
'top-right': 'toast-container--top-right',
|
||||||
'bottom-left': { bottom: 16, left: 16, flexDirection: 'column-reverse' },
|
'bottom-left': 'toast-container--bottom-left',
|
||||||
'bottom-right': { bottom: 16, right: 16, flexDirection: 'column-reverse' },
|
'bottom-right': 'toast-container--bottom-right',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getColors(t: ToastInstance) {
|
function getToastItemClass(t: ToastInstance): string {
|
||||||
switch (t.alertType) {
|
return `toast-item toast-item--${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':
|
function getProgressBarClass(t: ToastInstance): string {
|
||||||
return { bg: 'var(--color-red-100)', border: 'var(--color-red-400)', text: 'var(--text-primary)', bar: 'var(--color-red-500)' };
|
return `toast-progress-bar toast-progress-bar--${t.alertType}`;
|
||||||
case 'warning':
|
}
|
||||||
return { bg: 'var(--color-yellow-100)', border: 'var(--color-yellow-400)', text: 'var(--text-primary)', bar: 'var(--color-yellow-500)' };
|
|
||||||
case 'neutral':
|
function getActionButtonClass(t: ToastInstance): string {
|
||||||
default:
|
return `toast-action-button toast-action-button--${t.alertType}`;
|
||||||
return { bg: 'var(--bg-surface)', border: 'var(--border-default)', text: 'var(--text-primary)', bar: 'var(--color-gray-500)' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultIconName(t: ToastInstance): string {
|
function getDefaultIconName(t: ToastInstance): string {
|
||||||
@ -51,42 +50,33 @@ export default function ToastRenderer() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(Object.keys(grouped) as ToastLocation[]).map((loc) => (
|
{(Object.keys(grouped) as ToastLocation[]).map((loc) => (
|
||||||
<div key={loc} style={{ position: 'fixed', zIndex: 1200, display: 'flex', gap: 12, pointerEvents: 'none', ...locationToClass[loc] }}>
|
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||||
{grouped[loc].map(t => {
|
{grouped[loc].map(t => {
|
||||||
const colors = getColors(t);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
role="status"
|
role="status"
|
||||||
style={{
|
className={getToastItemClass(t)}
|
||||||
minWidth: 320,
|
|
||||||
maxWidth: 560,
|
|
||||||
background: t.alertType === 'neutral' ? 'var(--bg-surface)' : colors.bg,
|
|
||||||
color: colors.text,
|
|
||||||
border: `1px solid ${t.alertType === 'neutral' ? 'var(--border-default)' : colors.border}`,
|
|
||||||
boxShadow: 'var(--shadow-lg)',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 16,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 8,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Top row: Icon + Title + Controls */}
|
{/* Top row: Icon + Title + Controls */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div className="toast-header">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div style={{ width: 24, height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div className="toast-icon">
|
||||||
{t.icon ?? (
|
{t.icon ?? (
|
||||||
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} style={{ color: colors.bar }} />
|
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title + count badge */}
|
||||||
<div style={{ fontWeight: 700, flex: 1 }}>{t.title}</div>
|
<div className="toast-title-container">
|
||||||
|
<span>{t.title}</span>
|
||||||
|
{typeof t.count === 'number' && t.count > 1 && (
|
||||||
|
<span className="toast-count-badge">{t.count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
<div className="toast-controls">
|
||||||
{t.expandable && (
|
{t.expandable && (
|
||||||
<button
|
<button
|
||||||
aria-label="Toggle details"
|
aria-label="Toggle details"
|
||||||
@ -94,20 +84,7 @@ export default function ToastRenderer() {
|
|||||||
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
|
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
|
||||||
window.dispatchEvent(evt);
|
window.dispatchEvent(evt);
|
||||||
}}
|
}}
|
||||||
style={{
|
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 999,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transform: t.isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
|
||||||
transition: 'transform 160ms ease',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
||||||
</button>
|
</button>
|
||||||
@ -115,53 +92,38 @@ export default function ToastRenderer() {
|
|||||||
<button
|
<button
|
||||||
aria-label="Dismiss"
|
aria-label="Dismiss"
|
||||||
onClick={() => dismiss(t.id)}
|
onClick={() => dismiss(t.id)}
|
||||||
style={{
|
className="toast-button"
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 999,
|
|
||||||
border: 'none',
|
|
||||||
background: 'transparent',
|
|
||||||
color: 'var(--text-secondary)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Progress bar - always show when present */}
|
||||||
|
{typeof t.progress === 'number' && (
|
||||||
|
<div className="toast-progress-container">
|
||||||
|
<div
|
||||||
|
className={getProgressBarClass(t)}
|
||||||
|
style={{ width: `${t.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body content - only show when expanded */}
|
||||||
{(t.isExpanded || !t.expandable) && (
|
{(t.isExpanded || !t.expandable) && (
|
||||||
<div
|
<div className="toast-body">
|
||||||
style={{
|
{t.body}
|
||||||
fontSize: 14,
|
</div>
|
||||||
opacity: 0.9,
|
)}
|
||||||
marginTop: 8,
|
|
||||||
}}
|
{/* Button - always show when present, positioned below body */}
|
||||||
>
|
{t.buttonText && t.buttonCallback && (
|
||||||
{t.body}
|
<div className="toast-action-container">
|
||||||
{t.buttonText && t.buttonCallback && (
|
|
||||||
<button
|
<button
|
||||||
onClick={t.buttonCallback}
|
onClick={t.buttonCallback}
|
||||||
style={{
|
className={getActionButtonClass(t)}
|
||||||
marginTop: 12,
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: `1px solid ${colors.border}`,
|
|
||||||
background: 'transparent',
|
|
||||||
color: colors.text,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t.buttonText}
|
{t.buttonText}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
{typeof t.progress === 'number' && (
|
|
||||||
<div style={{ marginTop: 12, height: 6, background: 'var(--bg-muted)', borderRadius: 999, overflow: 'hidden' }}>
|
|
||||||
<div style={{ width: `${t.progress}%`, height: '100%', background: colors.bar, transition: 'width 160ms ease' }} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -30,6 +30,8 @@ export interface ToastInstance extends Omit<ToastOptions, 'id' | 'progressBarPer
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
expandable: boolean;
|
expandable: boolean;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
|
/** Number of coalesced duplicates */
|
||||||
|
count?: number;
|
||||||
/** internal progress normalized 0..100 */
|
/** internal progress normalized 0..100 */
|
||||||
progress?: number;
|
progress?: number;
|
||||||
/** if progress completed, briefly show check icon */
|
/** if progress completed, briefly show check icon */
|
||||||
|
@ -21,7 +21,8 @@ export const initialFileContextState: FileContextState = {
|
|||||||
selectedPageNumbers: [],
|
selectedPageNumbers: [],
|
||||||
isProcessing: false,
|
isProcessing: false,
|
||||||
processingProgress: 0,
|
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': {
|
case 'PIN_FILE': {
|
||||||
const { fileId } = action.payload;
|
const { fileId } = action.payload;
|
||||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||||
|
@ -558,5 +558,8 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
|
|||||||
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
||||||
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
||||||
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_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' })
|
||||||
});
|
});
|
||||||
|
@ -19,6 +19,8 @@ export const shouldProcessFilesSeparately = (
|
|||||||
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
(parameters.fromExtension === 'pdf' && isImageFormat(parameters.toExtension)) ||
|
||||||
// PDF to PDF/A conversions (each PDF should be processed separately)
|
// PDF to PDF/A conversions (each PDF should be processed separately)
|
||||||
(parameters.fromExtension === 'pdf' && parameters.toExtension === 'pdfa') ||
|
(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)
|
// Web files to PDF conversions (each web file should generate its own PDF)
|
||||||
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
|
((isWebFormat(parameters.fromExtension) || parameters.fromExtension === 'web') &&
|
||||||
parameters.toExtension === 'pdf') ||
|
parameters.toExtension === 'pdf') ||
|
||||||
|
@ -9,6 +9,9 @@ const buildFormData = (parameters: MergeParameters, files: File[]): FormData =>
|
|||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
formData.append("fileInput", 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("sortType", "orderProvided"); // Always use orderProvided since UI handles sorting
|
||||||
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
|
formData.append("removeCertSign", parameters.removeDigitalSignature.toString());
|
||||||
formData.append("generateToc", parameters.generateTableOfContents.toString());
|
formData.append("generateToc", parameters.generateTableOfContents.toString());
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import axios, { CancelTokenSource } from '../../../services/http';
|
import axios, { CancelTokenSource } from '../../../services/http';
|
||||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
|
import { isEmptyOutput } from '../../../services/errorUtils';
|
||||||
import type { ProcessingProgress } from './useToolState';
|
import type { ProcessingProgress } from './useToolState';
|
||||||
|
|
||||||
export interface ApiCallsConfig<TParams = void> {
|
export interface ApiCallsConfig<TParams = void> {
|
||||||
@ -19,9 +20,11 @@ export const useToolApiCalls = <TParams = void>() => {
|
|||||||
validFiles: File[],
|
validFiles: File[],
|
||||||
config: ApiCallsConfig<TParams>,
|
config: ApiCallsConfig<TParams>,
|
||||||
onProgress: (progress: ProcessingProgress) => void,
|
onProgress: (progress: ProcessingProgress) => void,
|
||||||
onStatus: (status: string) => void
|
onStatus: (status: string) => void,
|
||||||
): Promise<File[]> => {
|
markFileError?: (fileId: string) => void,
|
||||||
|
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
|
||||||
const processedFiles: File[] = [];
|
const processedFiles: File[] = [];
|
||||||
|
const successSourceIds: string[] = [];
|
||||||
const failedFiles: string[] = [];
|
const failedFiles: string[] = [];
|
||||||
const total = validFiles.length;
|
const total = validFiles.length;
|
||||||
|
|
||||||
@ -31,16 +34,19 @@ export const useToolApiCalls = <TParams = void>() => {
|
|||||||
for (let i = 0; i < validFiles.length; i++) {
|
for (let i = 0; i < validFiles.length; i++) {
|
||||||
const file = validFiles[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 });
|
onProgress({ current: i + 1, total, currentFileName: file.name });
|
||||||
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = config.buildFormData(params, file);
|
const formData = config.buildFormData(params, file);
|
||||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
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, {
|
const response = await axios.post(endpoint, formData, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
cancelToken: cancelTokenRef.current.token,
|
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)
|
// Forward to shared response processor (uses tool-specific responseHandler if provided)
|
||||||
const responseFiles = await processResponse(
|
const responseFiles = await processResponse(
|
||||||
@ -50,14 +56,27 @@ export const useToolApiCalls = <TParams = void>() => {
|
|||||||
config.responseHandler,
|
config.responseHandler,
|
||||||
config.preserveBackendFilename ? response.headers : undefined
|
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);
|
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) {
|
} catch (error) {
|
||||||
if (axios.isCancel(error)) {
|
if (axios.isCancel(error)) {
|
||||||
throw new Error('Operation was cancelled');
|
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);
|
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 = <TParams = void>() => {
|
|||||||
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
|
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(() => {
|
const cancelOperation = useCallback(() => {
|
||||||
|
@ -7,6 +7,7 @@ import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
|||||||
import { useToolResources } from './useToolResources';
|
import { useToolResources } from './useToolResources';
|
||||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
||||||
|
import { FILE_EVENTS } from '../../../services/errorUtils';
|
||||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||||
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
||||||
import { ToolOperation } from '../../../types/file';
|
import { ToolOperation } from '../../../types/file';
|
||||||
@ -148,6 +149,7 @@ export const useToolOperation = <TParams>(
|
|||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
|
const { actions: fileActions } = useFileContext();
|
||||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||||
|
|
||||||
@ -168,7 +170,16 @@ export const useToolOperation = <TParams>(
|
|||||||
return;
|
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) {
|
if (validFiles.length === 0) {
|
||||||
actions.setError(t('noValidFiles', 'No valid files to process'));
|
actions.setError(t('noValidFiles', 'No valid files to process'));
|
||||||
return;
|
return;
|
||||||
@ -183,8 +194,19 @@ export const useToolOperation = <TParams>(
|
|||||||
// Prepare files with history metadata injection (for PDFs)
|
// Prepare files with history metadata injection (for PDFs)
|
||||||
actions.setStatus('Processing files...');
|
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 processedFiles: File[];
|
||||||
|
let successSourceIds: string[] = [];
|
||||||
|
|
||||||
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
|
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
|
||||||
const filesForAPI = extractFiles(validFiles);
|
const filesForAPI = extractFiles(validFiles);
|
||||||
@ -199,13 +221,18 @@ export const useToolOperation = <TParams>(
|
|||||||
responseHandler: config.responseHandler,
|
responseHandler: config.responseHandler,
|
||||||
preserveBackendFilename: config.preserveBackendFilename
|
preserveBackendFilename: config.preserveBackendFilename
|
||||||
};
|
};
|
||||||
processedFiles = await processFiles(
|
console.debug('[useToolOperation] Multi-file start', { count: filesForAPI.length });
|
||||||
|
const result = await processFiles(
|
||||||
params,
|
params,
|
||||||
filesForAPI,
|
filesForAPI,
|
||||||
apiCallsConfig,
|
apiCallsConfig,
|
||||||
actions.setProgress,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case ToolType.multiFile: {
|
case ToolType.multiFile: {
|
||||||
@ -235,13 +262,63 @@ export const useToolOperation = <TParams>(
|
|||||||
processedFiles = await extractAllZipFiles(response.data);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case ToolType.custom:
|
case ToolType.custom: {
|
||||||
actions.setStatus('Processing files...');
|
actions.setStatus('Processing files...');
|
||||||
processedFiles = await config.customProcessor(params, filesForAPI);
|
processedFiles = await config.customProcessor(params, filesForAPI);
|
||||||
|
// Try to map outputs back to inputs by filename (before extension)
|
||||||
|
const inputBaseNames = new Map<string, string>();
|
||||||
|
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;
|
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) {
|
if (processedFiles.length > 0) {
|
||||||
@ -286,29 +363,38 @@ export const useToolOperation = <TParams>(
|
|||||||
const processedFileMetadataArray = await Promise.all(
|
const processedFileMetadataArray = await Promise.all(
|
||||||
processedFiles.map(file => generateProcessedFileMetadata(file))
|
processedFiles.map(file => generateProcessedFileMetadata(file))
|
||||||
);
|
);
|
||||||
const shouldBranchHistory = processedFiles.length != inputStirlingFileStubs.length;
|
// Always create child stubs linking back to the successful source inputs
|
||||||
// Create output stubs with fresh metadata (no inheritance of stale processedFile data)
|
const successInputStubs = successSourceIds
|
||||||
const outputStirlingFileStubs = shouldBranchHistory
|
.map((id) => selectors.getStirlingFileStub(id as any))
|
||||||
? processedFiles.map((file, index) =>
|
.filter(Boolean) as StirlingFileStub[];
|
||||||
createNewStirlingFileStub(file, undefined, thumbnails[index], processedFileMetadataArray[index])
|
|
||||||
)
|
if (successInputStubs.length !== processedFiles.length) {
|
||||||
: processedFiles.map((resultingFile, index) =>
|
console.warn('[useToolOperation] Mismatch successInputStubs vs outputs', {
|
||||||
createChildStub(
|
successInputStubs: successInputStubs.length,
|
||||||
inputStirlingFileStubs[index],
|
outputs: processedFiles.length,
|
||||||
newToolOperation,
|
});
|
||||||
resultingFile,
|
}
|
||||||
thumbnails[index],
|
|
||||||
processedFileMetadataArray[index]
|
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
|
// Create StirlingFile objects from processed files and child stubs
|
||||||
const outputStirlingFiles = processedFiles.map((file, index) => {
|
const outputStirlingFiles = processedFiles.map((file, index) => {
|
||||||
const childStub = outputStirlingFileStubs[index];
|
const childStub = outputStirlingFileStubs[index];
|
||||||
return createStirlingFile(file, childStub.id);
|
return createStirlingFile(file, childStub.id);
|
||||||
});
|
});
|
||||||
|
// Build consumption arrays aligned to the successful source IDs
|
||||||
const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs);
|
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)
|
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||||
lastOperationRef.current = {
|
lastOperationRef.current = {
|
||||||
@ -320,10 +406,40 @@ export const useToolOperation = <TParams>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} 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);
|
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||||
actions.setError(errorMessage);
|
actions.setError(errorMessage);
|
||||||
actions.setStatus('');
|
actions.setStatus('');
|
||||||
} finally {
|
} finally {
|
||||||
|
window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener);
|
||||||
actions.setLoading(false);
|
actions.setLoading(false);
|
||||||
actions.setProgress(null);
|
actions.setProgress(null);
|
||||||
}
|
}
|
||||||
|
47
frontend/src/services/errorUtils.ts
Normal file
47
frontend/src/services/errorUtils.ts
Normal file
@ -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<T = any>(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<any> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,38 +1,79 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { alert } from '../components/toast';
|
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)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const status = error.response?.status;
|
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 body = ((): string => {
|
||||||
const data = error.response?.data as any;
|
const data = parsed;
|
||||||
if (!data) return '';
|
if (!data) return typeof raw === 'string' ? raw : '';
|
||||||
if (typeof data === 'string') return data;
|
const ids = extractIds();
|
||||||
|
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||||
if (data?.message) return data.message as string;
|
if (data?.message) return data.message as string;
|
||||||
|
if (typeof raw === 'string') return raw;
|
||||||
try { return JSON.stringify(data); } catch { return ''; }
|
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 {
|
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 {
|
} catch {
|
||||||
return 'Unknown network error';
|
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install Axios response error interceptor
|
// Install Axios response error interceptor
|
||||||
axios.interceptors.response.use(
|
axios.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error) => {
|
||||||
const msg = extractAxiosErrorMessage(error);
|
const { title, body } = extractAxiosErrorMessage(error);
|
||||||
alert({
|
// If server sends structured file IDs for failures, also mark them errored in UI
|
||||||
alertType: 'error',
|
try {
|
||||||
title: 'Request failed',
|
const raw = (error?.response?.data) as any;
|
||||||
body: msg,
|
const data = await normalizeAxiosErrorData(raw);
|
||||||
expandable: true,
|
const ids = extractErrorFileIds(data);
|
||||||
isPersistentPopup: false,
|
if (ids && ids.length > 0) broadcastErroredFiles(ids);
|
||||||
});
|
} catch {}
|
||||||
|
alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false });
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -52,13 +93,9 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
alert({
|
const title = titleForStatus(res.status);
|
||||||
alertType: 'error',
|
const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText);
|
||||||
title: `Request failed (${res.status})`,
|
alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false });
|
||||||
body: detail || res.statusText,
|
|
||||||
expandable: true,
|
|
||||||
isPersistentPopup: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
@ -222,6 +222,8 @@
|
|||||||
--bulk-card-bg: #ffffff; /* white background for cards */
|
--bulk-card-bg: #ffffff; /* white background for cards */
|
||||||
--bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */
|
--bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */
|
||||||
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
||||||
|
--unsupported-bar-bg: #5a616e;
|
||||||
|
--unsupported-bar-border: #6B7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-mantine-color-scheme="dark"] {
|
[data-mantine-color-scheme="dark"] {
|
||||||
@ -410,7 +412,8 @@
|
|||||||
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
|
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
|
||||||
--bulk-card-border: var(--border-default); /* default border for cards and buttons */
|
--bulk-card-border: var(--border-default); /* default border for cards and buttons */
|
||||||
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
||||||
|
--unsupported-bar-bg: #1F2329;
|
||||||
|
--unsupported-bar-border: #4B525A;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropzone drop state styling */
|
/* Dropzone drop state styling */
|
||||||
|
@ -219,6 +219,7 @@ export interface FileContextState {
|
|||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
processingProgress: number;
|
processingProgress: number;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
|
errorFileIds: FileId[]; // files that errored during processing
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,6 +242,9 @@ export type FileContextAction =
|
|||||||
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
|
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
|
||||||
| { type: 'CLEAR_SELECTIONS' }
|
| { type: 'CLEAR_SELECTIONS' }
|
||||||
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
|
| { 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)
|
// Navigation guard actions (minimal for file-related unsaved changes only)
|
||||||
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||||
@ -269,6 +273,9 @@ export interface FileContextActions {
|
|||||||
setSelectedFiles: (fileIds: FileId[]) => void;
|
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||||
setSelectedPages: (pageNumbers: number[]) => void;
|
setSelectedPages: (pageNumbers: number[]) => void;
|
||||||
clearSelections: () => void;
|
clearSelections: () => void;
|
||||||
|
markFileError: (fileId: FileId) => void;
|
||||||
|
clearFileError: (fileId: FileId) => void;
|
||||||
|
clearAllFileErrors: () => void;
|
||||||
|
|
||||||
// Processing state - simple flags only
|
// Processing state - simple flags only
|
||||||
setProcessing: (isProcessing: boolean, progress?: number) => void;
|
setProcessing: (isProcessing: boolean, progress?: number) => void;
|
||||||
|
Loading…
Reference in New Issue
Block a user