mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-26 17:52:59 +02:00
Merge branch 'V2' into invert-colors
This commit is contained in:
commit
88d2707f3f
@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@ -111,6 +114,32 @@ public class MergeController {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse client file IDs from JSON string
|
||||
private String[] parseClientFileIds(String clientFileIds) {
|
||||
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
try {
|
||||
// Simple JSON array parsing - remove brackets and split by comma
|
||||
String trimmed = clientFileIds.trim();
|
||||
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
||||
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
if (inside.isEmpty()) {
|
||||
return new String[0];
|
||||
}
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
|
||||
}
|
||||
return new String[0];
|
||||
}
|
||||
|
||||
// Adds a table of contents to the merged document using filenames as chapter titles
|
||||
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
|
||||
// Create the document outline
|
||||
@ -177,15 +206,48 @@ public class MergeController {
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
long totalSize = 0;
|
||||
for (MultipartFile multipartFile : files) {
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
MultipartFile multipartFile = files[index];
|
||||
totalSize += multipartFile.getSize();
|
||||
File tempFile =
|
||||
GeneralUtils.convertMultipartFileToFile(
|
||||
multipartFile); // Convert MultipartFile to File
|
||||
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
|
||||
|
||||
// Pre-validate each PDF so we can report which one(s) are broken
|
||||
// Use the original MultipartFile to avoid deleting the tempFile during validation
|
||||
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
|
||||
// OK
|
||||
} catch (IOException e) {
|
||||
ExceptionUtils.logException("PDF pre-validate", e);
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
}
|
||||
|
||||
if (!invalidIndexes.isEmpty()) {
|
||||
// Parse client file IDs (always present from frontend)
|
||||
String[] clientIds = parseClientFileIds(request.getClientFileIds());
|
||||
|
||||
// Map invalid indexes to client IDs
|
||||
List<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();
|
||||
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -6,6 +6,11 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Add .mjs MIME type mapping
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
@ -90,6 +95,14 @@ http {
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
|
||||
# Serve .mjs files with correct MIME type (must come before general static assets)
|
||||
location ~* \.mjs$ {
|
||||
try_files $uri =404;
|
||||
add_header Content-Type "text/javascript; charset=utf-8" always;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
|
251
frontend/package-lock.json
generated
251
frontend/package-lock.json
generated
@ -10,21 +10,22 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.2.1",
|
||||
"@embedpdf/core": "^1.3.0",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.2.1",
|
||||
"@embedpdf/plugin-loader": "^1.2.1",
|
||||
"@embedpdf/plugin-pan": "^1.2.1",
|
||||
"@embedpdf/plugin-render": "^1.2.1",
|
||||
"@embedpdf/plugin-rotate": "^1.2.1",
|
||||
"@embedpdf/plugin-scroll": "^1.2.1",
|
||||
"@embedpdf/plugin-search": "^1.2.1",
|
||||
"@embedpdf/plugin-selection": "^1.2.1",
|
||||
"@embedpdf/plugin-spread": "^1.2.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.2.1",
|
||||
"@embedpdf/plugin-tiling": "^1.2.1",
|
||||
"@embedpdf/plugin-viewport": "^1.2.1",
|
||||
"@embedpdf/plugin-zoom": "^1.2.1",
|
||||
"@embedpdf/plugin-export": "^1.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.0",
|
||||
"@embedpdf/plugin-loader": "^1.3.0",
|
||||
"@embedpdf/plugin-pan": "^1.3.0",
|
||||
"@embedpdf/plugin-render": "^1.3.0",
|
||||
"@embedpdf/plugin-rotate": "^1.3.0",
|
||||
"@embedpdf/plugin-scroll": "^1.3.0",
|
||||
"@embedpdf/plugin-search": "^1.3.0",
|
||||
"@embedpdf/plugin-selection": "^1.3.0",
|
||||
"@embedpdf/plugin-spread": "^1.3.0",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.0",
|
||||
"@embedpdf/plugin-tiling": "^1.3.0",
|
||||
"@embedpdf/plugin-viewport": "^1.3.0",
|
||||
"@embedpdf/plugin-zoom": "^1.3.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@ -488,12 +489,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.2.1.tgz",
|
||||
"integrity": "sha512-2VwRPsN3+LmaBrD8TCN1t1ni/Vc9CxAfl/SApDjZYwE7zOieQT4ZHt+nkgF0F4I3xSgvvyHDjmOonhjBIrT6xA==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.0.tgz",
|
||||
"integrity": "sha512-KEic1NA9JrtNRoTq3O3m93YTglRKweR6uqjzX3sLGCmy+LsUjiH5WOCJAztlSlmZEXysAlZlyzG/09gz4tpBAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.2.1",
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/engines": "1.3.0",
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@ -503,13 +505,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.2.1.tgz",
|
||||
"integrity": "sha512-nhycZ7Buq2B34dcpo6n7RdFwdhwTvKzvnRy7QX+uU00Dz5vftkCG4OK+pBVzxE4y7vAu+Yb4wNpdc7HmIj3B6w==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.0.tgz",
|
||||
"integrity": "sha512-6WbYwxtCCjOazEMGKbhKRkos6S1VkzI4R2u6dUuIsUw9G2HLP4bwJCBKj9A0FuMAJkKQ3VL5eVCSGfqaCaRoyQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1",
|
||||
"@embedpdf/pdfium": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0",
|
||||
"@embedpdf/pdfium": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@ -519,26 +521,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.2.1.tgz",
|
||||
"integrity": "sha512-FzJU51jsqihfgt50B00FEpgyym87/Dn2iGmMq4++Vu/oO6qBx/y69m4/cCAh4p4KkTJsvKNWC7T7dwSKa0FjHA==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.0.tgz",
|
||||
"integrity": "sha512-LIY6T+nQoc1hi6nq1NlH6sR43J3PYOg9Bux8ouEnKjEGiZMgyd1cMxhBfrrY+Ft6DsSkqqujFOVEwjeYQYy3dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.2.1.tgz",
|
||||
"integrity": "sha512-QWf1jg7EqUlku2q6KYhlXCNfk5IAykFerPuzKJepHTeAEaRcAfu84fJgEsoUTCK4D6dfzVNp2Iuxw6Kv7MpSeg==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.0.tgz",
|
||||
"integrity": "sha512-rSBFYjxwQ58L/HcqR0l5Vv4G5t+CCOKlFYrDReTZYNN7fhzKPUWbXUn4ARahZWCNmF8svHumV2P4ArakJJviuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.2.1.tgz",
|
||||
"integrity": "sha512-HhEBuDjDNMH6wu76Eo3yHwjG01U1lNZShkOsFoib/rtx8HByTgZS8iVpovaOprr6gfS04ZLqWcsN1nt5qAH90w==",
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.0.tgz",
|
||||
"integrity": "sha512-R6VItLmXmXbb0/4AsH1YGUZd0c64K/8kxQd0XAvgUJwcL7Z4s8bLsqRx4sVQqwVllaPEJbAdFn1CC/ymkGB+fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.0.tgz",
|
||||
"integrity": "sha512-iMG7mW+4YpNjBeSAcC5kK9VnjwmNu71HTxVtKnN73t3EBfukbMH4y7Tp2ds+4I97H6vc18RK5xuUCSesEOBgww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -546,14 +565,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-loader": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.2.1.tgz",
|
||||
"integrity": "sha512-VblKErfEiHcVao18TfCmc0UJlKAkqxE29DaLJrXQHGUw/qc+pC9HlvMVpDz3+Eb13UafYS6ZUZuEng2/fQ+JJw==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.0.tgz",
|
||||
"integrity": "sha512-tkOa1UwFOimueSxxm2hRAAh64K75itDvUO6wHjb5X5s0Hx4DccfrJ7KusDhxBkeQLFXtZknPG0Q2/9T+joAqeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -561,16 +581,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.2.1.tgz",
|
||||
"integrity": "sha512-/BTOyRl31tvnCmoLs4qNPROMRLaG34jGYNyMQquB0uPUXZjwdMloikriwos91qCOLUrhvs4SaDpC3Ghv2BO5kA==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.0.tgz",
|
||||
"integrity": "sha512-tZxUpX9dvd/VDHCTqM9Yjss4M8pkJWFUA5GDNmPkExRXIASuB98wEP8fh0rQt13TEZ30rV77cEsNXngju56kjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.0",
|
||||
"@embedpdf/plugin-viewport": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -578,14 +599,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.2.1.tgz",
|
||||
"integrity": "sha512-iMfuVJqttJmm7Zb8oOaqNVNrC3NS57bDNNAc4MIc2f2TxIFSznvBPlwWN+PN45qNcTQiGzFc1ZMqIQDOG4qFnQ==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.0.tgz",
|
||||
"integrity": "sha512-ZyxoGIIUa2HBLt1IB64EdWqBxHh01AX/1HJ7/cnoQK1h/oKXRbMAX6Mb23JCh2PGa4sGeyV3psoFMt037Eew3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -593,14 +615,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.2.1.tgz",
|
||||
"integrity": "sha512-UhHds5donLDXm3i9nKrhSmo3yawVtjb6gID0MDrhj3+Lci/YQ3wDvGUhk7dNmgLcOt7G8pMa0wesnnpVWirUXA==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.0.tgz",
|
||||
"integrity": "sha512-EyLLwf9VKQCsMRTe0KwGe+ZAaFqmcYS5WW/qqPBNfvSuBaybNpdI+C72IQFr41X7cYQV58OgEL3bfDb1MBPGHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -608,15 +631,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.2.1.tgz",
|
||||
"integrity": "sha512-I1haDXIOzs59uhOWEP6UvP5jzjcQHMLQuQbfRVJM0zdWU6t3jwSfcwPUI7iv4CAAepbuyJKL328yc8736r/FYw==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.0.tgz",
|
||||
"integrity": "sha512-o1n6Mkoc92BHAkoCX0mSLXgOj4uAkokNbvP+2QMijShzTsl95gU5UzK6siZ5o6WgZBznJcceYmLuPR5ODqZDWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-viewport": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -624,15 +648,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.2.1.tgz",
|
||||
"integrity": "sha512-sl9FBQzbOBtdmPpf6UI0bnWCTPWDkj47rTxyK07bpnGfGuFof4zhcxmMaFdyP7zqBh4Y9XqGu4A0uMTO2d/t7g==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.0.tgz",
|
||||
"integrity": "sha512-DilSRfPQR38picjx7eyyuXNeduD7hcW/PjT9DZrjXxfLrAQtd17CXJs7HtJevl1wErh/CCSvZlHhjp1++O6GAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-loader": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-loader": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -640,16 +665,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.2.1.tgz",
|
||||
"integrity": "sha512-wgG1X1sl6sed3pv7WLIO74SX0x3389/ax+/OLMty/LFbDNYMRO+n8ZQss8aUM700HARIqkPJy7UoSQt91o4nwA==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.0.tgz",
|
||||
"integrity": "sha512-1PEtreNofysaLxZvgO2CSNCxXhevjYnBdu4IHTFeJKXoq3ckKwkX8fJjyyN4D6+6uXZsnFkHhewl1yKCfKWAWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.0",
|
||||
"@embedpdf/plugin-viewport": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -657,15 +683,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.2.1.tgz",
|
||||
"integrity": "sha512-rpadnutT1wSdBQV7RQz40zYdKgCRgmJde/tamgB8oHQypcnZGQcAG6/ZfX5j12s9pG18hKwwL+KmMoBnXD9IjQ==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.0.tgz",
|
||||
"integrity": "sha512-oRLimcod8RhdknN94CQeG+0QndQeiZKIhFUCXDIGxN1Z/qvspZCUty2TC+1kc3G318nZi55pWWphq9sB7ZpqEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-loader": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-loader": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -673,32 +700,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.2.1.tgz",
|
||||
"integrity": "sha512-TjHPkK8p3+FDMLcUdb3/4VREjm+liVooufLPVZ3FCXHbiC0PeUkqnwAxpCS2Jw1n+EtkY8pefRdRJeZhO6plOQ==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.0.tgz",
|
||||
"integrity": "sha512-w2wzL7m6/sUF54sMVEi8Y8+7VE3BcZqI8THDqobkEkno4Dgmb77FHNPFD6YtAhaRmIoyPnlZf05RDd6Z8ohhkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-render": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-render": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.2.1.tgz",
|
||||
"integrity": "sha512-C9uOGVIsoxUw+uQMXfJFZ8ibRLQeNOnaKC2izjx967iGu0ZoecAv+mKtH/Ge0vMEKYM1109AlF5T2EGwKQW2YA==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.0.tgz",
|
||||
"integrity": "sha512-huYi4BJa9KSfqC424bEHw72KBLCR2rfApMeKnpUzAFSdWA6MSYmVBSk8ghnU7XbcLuL6fFBarNsziNrSSnVWTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-render": "1.2.1",
|
||||
"@embedpdf/plugin-scroll": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-render": "1.3.0",
|
||||
"@embedpdf/plugin-scroll": "1.3.0",
|
||||
"@embedpdf/plugin-viewport": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -706,14 +735,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.2.1.tgz",
|
||||
"integrity": "sha512-yvftOis7FLBjM3w2VYO5LXVKXoHkmFV/SPy7U6SbuLJTX126F4ohSij9euMHJjaqOgr5tBNvrf4xemVRglxM9w==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.0.tgz",
|
||||
"integrity": "sha512-AZ7U8DEgEQ8nK5kdrqtukLl5au9NE3mIlFmloyo6Ddrt2rN/Jw1Lt9dsl6wU20GcFQX+hWsg9uAJboLq6AdOCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1"
|
||||
"@embedpdf/models": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@ -721,18 +751,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.2.1.tgz",
|
||||
"integrity": "sha512-hsp/nM4C8q0FM9P6FkpQLbU8IYawUgmiYgD3HXqHWBVRk30OIaXs4N0KC9vsHwn8ZAiyLl7jhlAXpgoacH5xEQ==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.0.tgz",
|
||||
"integrity": "sha512-1VA9aFxoP+BoEpwlR0//jtlD9ESS8nhU8OGGHBRu7IgoWzIx4GqOHgpgXVxzFl9IaLOv69E9DVmwe/yaC6F+0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.2.1",
|
||||
"@embedpdf/models": "1.3.0",
|
||||
"hammerjs": "^2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.2.1",
|
||||
"@embedpdf/plugin-scroll": "1.2.1",
|
||||
"@embedpdf/plugin-viewport": "1.2.1",
|
||||
"@embedpdf/core": "1.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.0",
|
||||
"@embedpdf/plugin-scroll": "1.3.0",
|
||||
"@embedpdf/plugin-viewport": "1.3.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
|
@ -6,21 +6,22 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.2.1",
|
||||
"@embedpdf/core": "^1.3.0",
|
||||
"@embedpdf/engines": "^1.2.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.2.1",
|
||||
"@embedpdf/plugin-loader": "^1.2.1",
|
||||
"@embedpdf/plugin-pan": "^1.2.1",
|
||||
"@embedpdf/plugin-render": "^1.2.1",
|
||||
"@embedpdf/plugin-rotate": "^1.2.1",
|
||||
"@embedpdf/plugin-scroll": "^1.2.1",
|
||||
"@embedpdf/plugin-search": "^1.2.1",
|
||||
"@embedpdf/plugin-selection": "^1.2.1",
|
||||
"@embedpdf/plugin-spread": "^1.2.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.2.1",
|
||||
"@embedpdf/plugin-tiling": "^1.2.1",
|
||||
"@embedpdf/plugin-viewport": "^1.2.1",
|
||||
"@embedpdf/plugin-zoom": "^1.2.1",
|
||||
"@embedpdf/plugin-export": "^1.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.0",
|
||||
"@embedpdf/plugin-loader": "^1.3.0",
|
||||
"@embedpdf/plugin-pan": "^1.3.0",
|
||||
"@embedpdf/plugin-render": "^1.3.0",
|
||||
"@embedpdf/plugin-rotate": "^1.3.0",
|
||||
"@embedpdf/plugin-scroll": "^1.3.0",
|
||||
"@embedpdf/plugin-search": "^1.3.0",
|
||||
"@embedpdf/plugin-selection": "^1.3.0",
|
||||
"@embedpdf/plugin-spread": "^1.3.0",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.0",
|
||||
"@embedpdf/plugin-tiling": "^1.3.0",
|
||||
"@embedpdf/plugin-viewport": "^1.3.0",
|
||||
"@embedpdf/plugin-zoom": "^1.3.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
|
@ -74,7 +74,10 @@
|
||||
},
|
||||
"error": {
|
||||
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
|
||||
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
|
||||
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
|
||||
"_value": "Error",
|
||||
"dismissAllErrors": "Dismiss All Errors",
|
||||
"sorry": "Sorry for the issue!",
|
||||
"needHelp": "Need help / Found an issue?",
|
||||
"contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:",
|
||||
@ -433,8 +436,8 @@
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"tags": "detect,split,photos",
|
||||
"title": "Detect/Split Scanned photos",
|
||||
"desc": "Splits multiple photos from within a photo/PDF"
|
||||
"title": "Detect & Split Scanned Photos",
|
||||
"desc": "Detect and split scanned photos into separate pages"
|
||||
},
|
||||
"sign": {
|
||||
"tags": "signature,autograph",
|
||||
@ -1652,18 +1655,48 @@
|
||||
"tags": "separate,auto-detect,scans,multi-photo,organize",
|
||||
"selectText": {
|
||||
"1": "Angle Threshold:",
|
||||
"2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).",
|
||||
"2": "Tilt (in degrees) needed before we auto-straighten a photo.",
|
||||
"3": "Tolerance:",
|
||||
"4": "Determines the range of colour variation around the estimated background colour (default: 30).",
|
||||
"4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.",
|
||||
"5": "Minimum Area:",
|
||||
"6": "Sets the minimum area threshold for a photo (default: 10000).",
|
||||
"6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.",
|
||||
"7": "Minimum Contour Area:",
|
||||
"8": "Sets the minimum contour area threshold for a photo",
|
||||
"8": "Smallest edge/shape we consider when finding photos (filters dust and specks).",
|
||||
"9": "Border Size:",
|
||||
"10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)."
|
||||
"10": "Extra padding (in pixels) around each saved photo so edges aren't cut."
|
||||
},
|
||||
"info": "Python is not installed. It is required to run."
|
||||
},
|
||||
"scannerImageSplit": {
|
||||
"title": "Extracted Images",
|
||||
"submit": "Extract Image Scans",
|
||||
"error": {
|
||||
"failed": "An error occurred while extracting image scans."
|
||||
},
|
||||
"tooltip": {
|
||||
"title": "Photo Splitter",
|
||||
"whatThisDoes": "What this does",
|
||||
"whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.",
|
||||
"whenToUse": "When to use",
|
||||
"useCase1": "Scan whole album pages in one go",
|
||||
"useCase2": "Split flatbed batches into separate files",
|
||||
"useCase3": "Break collages into individual photos",
|
||||
"useCase4": "Pull photos from documents",
|
||||
"quickFixes": "Quick fixes",
|
||||
"problem1": "Photos not detected → increase Tolerance to 30-50",
|
||||
"problem2": "Too many false detections → increase Minimum Area to 15,000-20,000",
|
||||
"problem3": "Crops are too tight → increase Border Size to 5-10",
|
||||
"problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°",
|
||||
"problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000",
|
||||
"setupTips": "Setup tips",
|
||||
"tip1": "Use a plain, light background",
|
||||
"tip2": "Leave a small gap (≈1 cm) between photos",
|
||||
"tip3": "Scan at 300-600 DPI",
|
||||
"tip4": "Clean the scanner glass",
|
||||
"headsUp": "Heads-up",
|
||||
"headsUpDesc": "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
|
||||
}
|
||||
},
|
||||
"sign": {
|
||||
"title": "Sign",
|
||||
"header": "Sign PDFs",
|
||||
|
@ -68,6 +68,8 @@
|
||||
},
|
||||
"error": {
|
||||
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
|
||||
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
|
||||
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
|
||||
"_value": "Error",
|
||||
"sorry": "Sorry for the issue!",
|
||||
"needHelp": "Need help / Found an issue?",
|
||||
|
@ -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% {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||
Text, Center, Box, LoadingOverlay, Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
import { alert } from '../toast';
|
||||
import { downloadBlob } from '../../utils/downloadUtils';
|
||||
|
||||
|
||||
@ -46,8 +47,16 @@ const FileEditor = ({
|
||||
// Get file selection context
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [_status, _setStatus] = useState<string | null>(null);
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
// Toast helpers
|
||||
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
|
||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||
}, []);
|
||||
const showError = useCallback((message: string) => {
|
||||
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
@ -82,7 +91,7 @@ const FileEditor = ({
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
@ -157,18 +166,18 @@ const FileEditor = ({
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
showError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
showError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
@ -206,7 +215,7 @@ const FileEditor = ({
|
||||
} else {
|
||||
// Check if we've hit the selection limit
|
||||
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||
setStatus(`Maximum ${maxAllowed} files can be selected`);
|
||||
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
|
||||
return;
|
||||
}
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
@ -215,7 +224,7 @@ const FileEditor = ({
|
||||
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
|
||||
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
|
||||
|
||||
|
||||
// File reordering handler for drag and drop
|
||||
@ -271,8 +280,8 @@ const FileEditor = ({
|
||||
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
|
||||
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
|
||||
|
||||
|
||||
|
||||
@ -297,7 +306,7 @@ const FileEditor = ({
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, setStatus]);
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
@ -314,10 +323,10 @@ const FileEditor = ({
|
||||
try {
|
||||
// Use FileContext to handle loading stored files
|
||||
// The files are already in FileContext, just need to add them to active files
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
showStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
showError('Failed to load some files from storage');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -408,7 +417,7 @@ const FileEditor = ({
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
_onSetStatus={showStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
toolMode={toolMode}
|
||||
@ -428,31 +437,7 @@ const FileEditor = ({
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
</Box>
|
||||
</Dropzone>
|
||||
);
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
|
||||
import { alert } from '../toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
|
||||
@ -12,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState } from '../../contexts/file/fileHooks';
|
||||
import { FileId } from '../../types/file';
|
||||
import { formatFileSize } from '../../utils/fileUtils';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
@ -27,7 +29,7 @@ interface FileEditorThumbnailProps {
|
||||
onToggleFile: (fileId: FileId) => void;
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
@ -40,13 +42,15 @@ const FileEditorThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onSetStatus,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@ -187,9 +191,20 @@ const FileEditorThumbnail = ({
|
||||
// ---- Card interactions ----
|
||||
const handleCardClick = () => {
|
||||
if (!isSupported) return;
|
||||
// Clear error state if file has an error (click to clear error)
|
||||
if (hasError) {
|
||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||
}
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
if (!isSupported) return styles.headerUnsupported;
|
||||
return isSelected ? styles.headerSelected : styles.headerResting;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -199,10 +214,7 @@ const FileEditorThumbnail = ({
|
||||
data-selected={isSelected}
|
||||
data-supported={isSupported}
|
||||
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
|
||||
style={{
|
||||
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)',
|
||||
}}
|
||||
style={{opacity: isDragging ? 0.9 : 1}}
|
||||
tabIndex={0}
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
@ -210,13 +222,16 @@ const FileEditorThumbnail = ({
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className={`${styles.header} ${
|
||||
isSelected ? styles.headerSelected : styles.headerResting
|
||||
}`}
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
{hasError ? (
|
||||
<div className={styles.errorPill}>
|
||||
<span>{t('error._value', 'Error')}</span>
|
||||
</div>
|
||||
) : isSupported ? (
|
||||
<CheckboxIndicator
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleFile(file.id)}
|
||||
@ -263,10 +278,10 @@ const FileEditorThumbnail = ({
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
onSetStatus?.(`Unpinned ${file.name}`);
|
||||
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
onSetStatus?.(`Pinned ${file.name}`);
|
||||
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
}
|
||||
}
|
||||
setShowActions(false);
|
||||
@ -278,7 +293,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
|
||||
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
@ -290,7 +305,7 @@ const FileEditorThumbnail = ({
|
||||
className={`${styles.actionRow} ${styles.actionDanger}`}
|
||||
onClick={() => {
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
@ -328,7 +343,10 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* 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}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
|
@ -13,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
|
||||
import Viewer from '../viewer/Viewer';
|
||||
import LandingPage from '../shared/LandingPage';
|
||||
import Footer from '../shared/Footer';
|
||||
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
@ -151,6 +152,9 @@ export default function Workbench() {
|
||||
selectedToolKey={selectedToolId}
|
||||
/>
|
||||
|
||||
{/* Dismiss All Errors Button */}
|
||||
<DismissAllErrorsButton />
|
||||
|
||||
{/* Main content area */}
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
|
51
frontend/src/components/shared/DismissAllErrorsButton.tsx
Normal file
51
frontend/src/components/shared/DismissAllErrorsButton.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useFileActions } from '../../contexts/file/fileHooks';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface DismissAllErrorsButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Check if there are any files in error state
|
||||
const hasErrors = state.ui.errorFileIds.length > 0;
|
||||
|
||||
// Don't render if there are no errors
|
||||
if (!hasErrors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismissAllErrors = () => {
|
||||
actions.clearAllFileErrors();
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className={className}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<CloseIcon fontSize="small" />}
|
||||
onClick={handleDismissAllErrors}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default DismissAllErrorsButton;
|
@ -3,6 +3,9 @@ import { MantineProvider } from '@mantine/core';
|
||||
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
|
||||
import { mantineTheme } from '../../theme/mantineTheme';
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import { ToastProvider } from '../toast';
|
||||
import ToastRenderer from '../toast/ToastRenderer';
|
||||
import { ToastPortalBinder } from '../toast';
|
||||
|
||||
interface RainbowThemeContextType {
|
||||
themeMode: 'light' | 'dark' | 'rainbow';
|
||||
@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
|
||||
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
|
||||
style={{ minHeight: '100vh' }}
|
||||
>
|
||||
<ToastProvider>
|
||||
<ToastPortalBinder />
|
||||
{children}
|
||||
<ToastRenderer />
|
||||
</ToastProvider>
|
||||
</div>
|
||||
</MantineProvider>
|
||||
</RainbowThemeContext.Provider>
|
||||
|
@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
|
||||
import './rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '../../contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
|
||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@ -39,6 +39,7 @@ export default function RightRail() {
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
@ -65,11 +66,16 @@ export default function RightRail() {
|
||||
|
||||
const { totalItems, selectedCount } = getSelectionState();
|
||||
|
||||
// Get export state for viewer mode
|
||||
const exportState = viewerContext?.getExportState?.();
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
// Select all file IDs
|
||||
const allIds = state.files.ids;
|
||||
setSelectedFiles(allIds);
|
||||
// Clear any previous error flags when selecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
|
||||
@ -82,6 +88,8 @@ export default function RightRail() {
|
||||
const handleDeselectAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
setSelectedFiles([]);
|
||||
// Clear any previous error flags when deselecting all
|
||||
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
|
||||
return;
|
||||
}
|
||||
if (currentView === 'pageEditor') {
|
||||
@ -91,7 +99,10 @@ export default function RightRail() {
|
||||
}, [currentView, setSelectedFiles, pageEditorFunctions]);
|
||||
|
||||
const handleExportAll = useCallback(() => {
|
||||
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||
if (currentView === 'viewer') {
|
||||
// Use EmbedPDF export functionality for viewer mode
|
||||
viewerContext?.exportActions?.download();
|
||||
} else if (currentView === 'fileEditor') {
|
||||
// Download selected files (or all if none selected)
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
|
||||
@ -108,7 +119,7 @@ export default function RightRail() {
|
||||
// Export all pages (not just selected)
|
||||
pageEditorFunctions?.onExportAll?.();
|
||||
}
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext]);
|
||||
|
||||
const handleCloseSelected = useCallback(() => {
|
||||
if (currentView !== 'fileEditor') return;
|
||||
@ -440,7 +451,9 @@ export default function RightRail() {
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0}
|
||||
disabled={
|
||||
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
|
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
|
||||
});
|
||||
}
|
||||
```
|
||||
|
150
frontend/src/components/toast/ToastContext.tsx
Normal file
150
frontend/src/components/toast/ToastContext.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { ToastApi, ToastInstance, ToastOptions } from './types';
|
||||
|
||||
function normalizeProgress(value: number | undefined): number | undefined {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return undefined;
|
||||
// Accept 0..1 as fraction or 0..100 as percent
|
||||
if (value <= 1) return Math.max(0, Math.min(1, value)) * 100;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `toast_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
type DefaultOpts = Required<Pick<ToastOptions, 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>> &
|
||||
Partial<Omit<ToastOptions, 'id' | 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>>;
|
||||
|
||||
const defaultOptions: DefaultOpts = {
|
||||
alertType: 'neutral',
|
||||
title: '',
|
||||
isPersistentPopup: false,
|
||||
location: 'bottom-right',
|
||||
durationMs: 6000,
|
||||
};
|
||||
|
||||
interface ToastContextShape extends ToastApi {
|
||||
toasts: ToastInstance[];
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextShape | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastInstance[]>([]);
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
|
||||
const scheduleAutoDismiss = useCallback((toast: ToastInstance) => {
|
||||
if (toast.isPersistentPopup) return;
|
||||
window.clearTimeout(timers.current[toast.id]);
|
||||
timers.current[toast.id] = window.setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||
}, toast.durationMs);
|
||||
}, []);
|
||||
|
||||
const show = useCallback<ToastApi['show']>((options) => {
|
||||
const id = options.id || generateId();
|
||||
const hasButton = !!(options.buttonText && options.buttonCallback);
|
||||
const merged: ToastInstance = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
id,
|
||||
progress: normalizeProgress(options.progressBarPercentage),
|
||||
justCompleted: false,
|
||||
expandable: hasButton ? false : (options.expandable !== false),
|
||||
isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)),
|
||||
createdAt: Date.now(),
|
||||
} as ToastInstance;
|
||||
setToasts(prev => {
|
||||
// Coalesce duplicates by alertType + title + body text if no explicit id was provided
|
||||
if (!options.id) {
|
||||
const bodyText = typeof merged.body === 'string' ? merged.body : '';
|
||||
const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText);
|
||||
if (existingIndex !== -1) {
|
||||
const updated = [...prev];
|
||||
const existing = updated[existingIndex];
|
||||
const nextCount = (existing.count ?? 1) + 1;
|
||||
updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() };
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
const next = [...prev.filter(t => t.id !== id), merged];
|
||||
return next;
|
||||
});
|
||||
scheduleAutoDismiss(merged);
|
||||
return id;
|
||||
}, [scheduleAutoDismiss]);
|
||||
|
||||
const update = useCallback<ToastApi['update']>((id, updates) => {
|
||||
setToasts(prev => prev.map(t => {
|
||||
if (t.id !== id) return t;
|
||||
const progress = updates.progressBarPercentage !== undefined
|
||||
? normalizeProgress(updates.progressBarPercentage)
|
||||
: t.progress;
|
||||
|
||||
const next: ToastInstance = {
|
||||
...t,
|
||||
...updates,
|
||||
progress,
|
||||
} as ToastInstance;
|
||||
|
||||
// Detect completion
|
||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||
// On completion: finalize type as success unless explicitly provided otherwise
|
||||
next.justCompleted = false;
|
||||
if (!updates.alertType) {
|
||||
next.alertType = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateProgress = useCallback<ToastApi['updateProgress']>((id, progress) => {
|
||||
update(id, { progressBarPercentage: progress });
|
||||
}, [update]);
|
||||
|
||||
const dismiss = useCallback<ToastApi['dismiss']>((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
}, []);
|
||||
|
||||
const dismissAll = useCallback<ToastApi['dismissAll']>(() => {
|
||||
setToasts([]);
|
||||
Object.values(timers.current).forEach(t => window.clearTimeout(t));
|
||||
timers.current = {};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ToastContextShape>(() => ({
|
||||
toasts,
|
||||
show,
|
||||
update,
|
||||
updateProgress,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
}), [toasts, show, update, updateProgress, dismiss, dismissAll]);
|
||||
|
||||
// Handle expand/collapse toggles from renderer without widening API
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { id: string } | undefined;
|
||||
if (!detail?.id) return;
|
||||
setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t));
|
||||
};
|
||||
window.addEventListener('toast:toggle', handler as EventListener);
|
||||
return () => window.removeEventListener('toast:toggle', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
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);
|
||||
}
|
138
frontend/src/components/toast/ToastRenderer.tsx
Normal file
138
frontend/src/components/toast/ToastRenderer.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { useToast } from './ToastContext';
|
||||
import { ToastInstance, ToastLocation } from './types';
|
||||
import { LocalIcon } from '../shared/LocalIcon';
|
||||
import './ToastRenderer.css';
|
||||
|
||||
const locationToClass: Record<ToastLocation, string> = {
|
||||
'top-left': 'toast-container--top-left',
|
||||
'top-right': 'toast-container--top-right',
|
||||
'bottom-left': 'toast-container--bottom-left',
|
||||
'bottom-right': 'toast-container--bottom-right',
|
||||
};
|
||||
|
||||
function getToastItemClass(t: ToastInstance): string {
|
||||
return `toast-item toast-item--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getProgressBarClass(t: ToastInstance): string {
|
||||
return `toast-progress-bar toast-progress-bar--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getActionButtonClass(t: ToastInstance): string {
|
||||
return `toast-action-button toast-action-button--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getDefaultIconName(t: ToastInstance): string {
|
||||
switch (t.alertType) {
|
||||
case 'success':
|
||||
return 'check-circle-rounded';
|
||||
case 'error':
|
||||
return 'close-rounded';
|
||||
case 'warning':
|
||||
return 'warning-rounded';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'info-rounded';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ToastRenderer() {
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>((acc, t) => {
|
||||
const key = t.location;
|
||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
||||
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(grouped) as ToastLocation[]).map((loc) => (
|
||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||
{grouped[loc].map(t => {
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={getToastItemClass(t)}
|
||||
>
|
||||
{/* Top row: Icon + Title + Controls */}
|
||||
<div className="toast-header">
|
||||
{/* Icon */}
|
||||
<div className="toast-icon">
|
||||
{t.icon ?? (
|
||||
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + count badge */}
|
||||
<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 */}
|
||||
<div className="toast-controls">
|
||||
{t.expandable && (
|
||||
<button
|
||||
aria-label="Toggle details"
|
||||
onClick={() => {
|
||||
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
|
||||
window.dispatchEvent(evt);
|
||||
}}
|
||||
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="toast-button"
|
||||
>
|
||||
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
||||
</button>
|
||||
</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) && (
|
||||
<div className="toast-body">
|
||||
{t.body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button - always show when present, positioned below body */}
|
||||
{t.buttonText && t.buttonCallback && (
|
||||
<div className="toast-action-container">
|
||||
<button
|
||||
onClick={t.buttonCallback}
|
||||
className={getActionButtonClass(t)}
|
||||
>
|
||||
{t.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
61
frontend/src/components/toast/index.ts
Normal file
61
frontend/src/components/toast/index.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { ToastOptions } from './types';
|
||||
import { useToast, ToastProvider } from './ToastContext';
|
||||
import ToastRenderer from './ToastRenderer';
|
||||
|
||||
export { useToast, ToastProvider, ToastRenderer };
|
||||
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!_api) _api = createImperativeApi();
|
||||
|
||||
// Hook helper to wire context API back to singleton
|
||||
export function ToastPortalBinder() {
|
||||
const ctx = useToast();
|
||||
// Provide API once mounted
|
||||
_api!.provide(ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function alert(options: ToastOptions) {
|
||||
if (_api?.get()) {
|
||||
return _api.get()!.show(options);
|
||||
}
|
||||
// Queue until provider mounts
|
||||
let id = '';
|
||||
_api?.onReady((api) => { id = api.show(options); });
|
||||
return id;
|
||||
}
|
||||
|
||||
export function updateToast(id: string, options: Partial<ToastOptions>) {
|
||||
_api?.get()?.update(id, options);
|
||||
}
|
||||
|
||||
export function updateToastProgress(id: string, progress: number) {
|
||||
_api?.get()?.updateProgress(id, progress);
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
_api?.get()?.dismiss(id);
|
||||
}
|
||||
|
||||
export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
50
frontend/src/components/toast/types.ts
Normal file
50
frontend/src/components/toast/types.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||
|
||||
export interface ToastOptions {
|
||||
alertType?: ToastAlertType;
|
||||
title: string;
|
||||
body?: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonCallback?: () => void;
|
||||
isPersistentPopup?: boolean;
|
||||
location?: ToastLocation;
|
||||
icon?: ReactNode;
|
||||
/** number 0-1 as fraction or 0-100 as percent */
|
||||
progressBarPercentage?: number;
|
||||
/** milliseconds to auto-close if not persistent */
|
||||
durationMs?: number;
|
||||
/** optional id to control/update later */
|
||||
id?: string;
|
||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
export interface ToastInstance extends Omit<ToastOptions, 'id' | 'progressBarPercentage'> {
|
||||
id: string;
|
||||
alertType: ToastAlertType;
|
||||
isPersistentPopup: boolean;
|
||||
location: ToastLocation;
|
||||
durationMs: number;
|
||||
expandable: boolean;
|
||||
isExpanded: boolean;
|
||||
/** Number of coalesced duplicates */
|
||||
count?: number;
|
||||
/** internal progress normalized 0..100 */
|
||||
progress?: number;
|
||||
/** if progress completed, briefly show check icon */
|
||||
justCompleted: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
show: (options: ToastOptions) => string;
|
||||
update: (id: string, options: Partial<ToastOptions>) => void;
|
||||
updateProgress: (id: string, progress: number) => void;
|
||||
dismiss: (id: string) => void;
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NumberInput, Stack } from '@mantine/core';
|
||||
import { ScannerImageSplitParameters } from '../../../hooks/tools/scannerImageSplit/useScannerImageSplitParameters';
|
||||
|
||||
interface ScannerImageSplitSettingsProps {
|
||||
parameters: ScannerImageSplitParameters;
|
||||
onParameterChange: <K extends keyof ScannerImageSplitParameters>(key: K, value: ScannerImageSplitParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ScannerImageSplitSettings: React.FC<ScannerImageSplitSettingsProps> = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.1', 'Angle Threshold:')}
|
||||
description={t('ScannerImageSplit.selectText.2', 'Sets the minimum absolute angle required for the image to be rotated (default: 10).')}
|
||||
value={parameters.angle_threshold}
|
||||
onChange={(value) => onParameterChange('angle_threshold', Number(value) || 10)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.3', 'Tolerance:')}
|
||||
description={t('ScannerImageSplit.selectText.4', 'Determines the range of colour variation around the estimated background colour (default: 30).')}
|
||||
value={parameters.tolerance}
|
||||
onChange={(value) => onParameterChange('tolerance', Number(value) || 30)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.5', 'Minimum Area:')}
|
||||
description={t('ScannerImageSplit.selectText.6', 'Sets the minimum area threshold for a photo (default: 10000).')}
|
||||
value={parameters.min_area}
|
||||
onChange={(value) => onParameterChange('min_area', Number(value) || 10000)}
|
||||
min={0}
|
||||
step={100}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.7', 'Minimum Contour Area:')}
|
||||
description={t('ScannerImageSplit.selectText.8', 'Sets the minimum contour area threshold for a photo.')}
|
||||
value={parameters.min_contour_area}
|
||||
onChange={(value) => onParameterChange('min_contour_area', Number(value) || 500)}
|
||||
min={0}
|
||||
step={10}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label={t('ScannerImageSplit.selectText.9', 'Border Size:')}
|
||||
description={t('ScannerImageSplit.selectText.10', 'Sets the size of the border added and removed to prevent white borders in the output (default: 1).')}
|
||||
value={parameters.border_size}
|
||||
onChange={(value) => onParameterChange('border_size', Number(value) || 1)}
|
||||
min={0}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScannerImageSplitSettings;
|
54
frontend/src/components/tooltips/useScannerImageSplitTips.ts
Normal file
54
frontend/src/components/tooltips/useScannerImageSplitTips.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useScannerImageSplitTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('scannerImageSplit.tooltip.title', 'Photo Splitter')
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.whatThisDoes', 'What this does'),
|
||||
description: t('scannerImageSplit.tooltip.whatThisDoesDesc',
|
||||
'Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.'
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.whenToUse', 'When to use'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.useCase1', 'Scan whole album pages in one go'),
|
||||
t('scannerImageSplit.tooltip.useCase2', 'Split flatbed batches into separate files'),
|
||||
t('scannerImageSplit.tooltip.useCase3', 'Break collages into individual photos'),
|
||||
t('scannerImageSplit.tooltip.useCase4', 'Pull photos from documents')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.quickFixes', 'Quick fixes'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.problem1', 'Photos not detected → increase Tolerance to 30–50'),
|
||||
t('scannerImageSplit.tooltip.problem2', 'Too many false detections → increase Minimum Area to 15,000–20,000'),
|
||||
t('scannerImageSplit.tooltip.problem3', 'Crops are too tight → increase Border Size to 5–10'),
|
||||
t('scannerImageSplit.tooltip.problem4', 'Tilted photos not straightened → lower Angle Threshold to ~5°'),
|
||||
t('scannerImageSplit.tooltip.problem5', 'Dust/noise boxes → increase Minimum Contour Area to 1000–2000')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.setupTips', 'Setup tips'),
|
||||
bullets: [
|
||||
t('scannerImageSplit.tooltip.tip1', 'Use a plain, light background'),
|
||||
t('scannerImageSplit.tooltip.tip2', 'Leave a small gap (≈1 cm) between photos'),
|
||||
t('scannerImageSplit.tooltip.tip3', 'Scan at 300–600 DPI'),
|
||||
t('scannerImageSplit.tooltip.tip4', 'Clean the scanner glass')
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t('scannerImageSplit.tooltip.headsUp', 'Heads-up'),
|
||||
description: t('scannerImageSplit.tooltip.headsUpDesc',
|
||||
'Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy—try a lighter or darker background and leave more space.'
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
25
frontend/src/components/viewer/ExportAPIBridge.tsx
Normal file
25
frontend/src/components/viewer/ExportAPIBridge.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useExportCapability } from '@embedpdf/plugin-export/react';
|
||||
import { useViewer } from '../../contexts/ViewerContext';
|
||||
|
||||
/**
|
||||
* Component that runs inside EmbedPDF context and provides export functionality
|
||||
*/
|
||||
export function ExportAPIBridge() {
|
||||
const { provides: exportApi } = useExportCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
|
||||
useEffect(() => {
|
||||
if (exportApi) {
|
||||
// Register this bridge with ViewerContext
|
||||
registerBridge('export', {
|
||||
state: {
|
||||
canExport: true,
|
||||
},
|
||||
api: exportApi
|
||||
});
|
||||
}
|
||||
}, [exportApi, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
@ -17,6 +17,7 @@ import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||
import { Rotation } from '@embedpdf/models';
|
||||
import { CustomSearchLayer } from './CustomSearchLayer';
|
||||
import { ZoomAPIBridge } from './ZoomAPIBridge';
|
||||
@ -29,6 +30,7 @@ import { SpreadAPIBridge } from './SpreadAPIBridge';
|
||||
import { SearchAPIBridge } from './SearchAPIBridge';
|
||||
import { ThumbnailAPIBridge } from './ThumbnailAPIBridge';
|
||||
import { RotateAPIBridge } from './RotateAPIBridge';
|
||||
import { ExportAPIBridge } from './ExportAPIBridge';
|
||||
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
@ -112,6 +114,11 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
createPluginRegistration(RotatePluginPackage, {
|
||||
defaultRotation: Rotation.Degree0, // Start with no rotation
|
||||
}),
|
||||
|
||||
// Register export plugin for downloading PDFs
|
||||
createPluginRegistration(ExportPluginPackage, {
|
||||
defaultFileName: 'document.pdf',
|
||||
}),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
|
||||
@ -170,6 +177,7 @@ export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) {
|
||||
<SearchAPIBridge />
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
<ExportAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
|
@ -51,6 +51,11 @@ interface ThumbnailAPIWrapper {
|
||||
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
|
||||
}
|
||||
|
||||
interface ExportAPIWrapper {
|
||||
download: () => void;
|
||||
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
|
||||
}
|
||||
|
||||
|
||||
// State interfaces - represent the shape of data from each bridge
|
||||
interface ScrollState {
|
||||
@ -93,6 +98,10 @@ interface SearchState {
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
interface ExportState {
|
||||
canExport: boolean;
|
||||
}
|
||||
|
||||
// Bridge registration interface - bridges register with state and API
|
||||
interface BridgeRef<TState = unknown, TApi = unknown> {
|
||||
state: TState;
|
||||
@ -122,6 +131,7 @@ interface ViewerContextType {
|
||||
getRotationState: () => RotationState;
|
||||
getSearchState: () => SearchState;
|
||||
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
|
||||
getExportState: () => ExportState;
|
||||
|
||||
// Immediate update callbacks
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
|
||||
@ -179,6 +189,11 @@ interface ViewerContextType {
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
exportActions: {
|
||||
download: () => void;
|
||||
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
||||
};
|
||||
|
||||
// Bridge registration - internal use by bridges
|
||||
registerBridge: (type: string, ref: BridgeRef) => void;
|
||||
}
|
||||
@ -203,6 +218,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
|
||||
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
|
||||
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
|
||||
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
|
||||
});
|
||||
|
||||
// Immediate zoom callback for responsive display updates
|
||||
@ -238,6 +254,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
case 'thumbnail':
|
||||
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
|
||||
break;
|
||||
case 'export':
|
||||
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@ -278,6 +297,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
return bridgeRefs.current.thumbnail?.api || null;
|
||||
};
|
||||
|
||||
const getExportState = (): ExportState => {
|
||||
return bridgeRefs.current.export?.state || { canExport: false };
|
||||
};
|
||||
|
||||
// Action handlers - call APIs directly
|
||||
const scrollActions = {
|
||||
scrollToPage: (page: number) => {
|
||||
@ -473,6 +496,28 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportActions = {
|
||||
download: () => {
|
||||
const api = bridgeRefs.current.export?.api;
|
||||
if (api?.download) {
|
||||
api.download();
|
||||
}
|
||||
},
|
||||
saveAsCopy: async () => {
|
||||
const api = bridgeRefs.current.export?.api;
|
||||
if (api?.saveAsCopy) {
|
||||
try {
|
||||
const result = api.saveAsCopy();
|
||||
return await result.toPromise();
|
||||
} catch (error) {
|
||||
console.error('Failed to save PDF copy:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
|
||||
immediateZoomUpdateCallback.current = callback;
|
||||
};
|
||||
@ -507,6 +552,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
getRotationState,
|
||||
getSearchState,
|
||||
getThumbnailAPI,
|
||||
getExportState,
|
||||
|
||||
// Immediate updates
|
||||
registerImmediateZoomUpdate,
|
||||
@ -522,6 +568,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
spreadActions,
|
||||
rotationActions,
|
||||
searchActions,
|
||||
exportActions,
|
||||
|
||||
// Bridge registration
|
||||
registerBridge,
|
||||
|
@ -21,7 +21,8 @@ export const initialFileContextState: FileContextState = {
|
||||
selectedPageNumbers: [],
|
||||
isProcessing: false,
|
||||
processingProgress: 0,
|
||||
hasUnsavedChanges: false
|
||||
hasUnsavedChanges: false,
|
||||
errorFileIds: []
|
||||
}
|
||||
};
|
||||
|
||||
@ -217,6 +218,30 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
|
||||
};
|
||||
}
|
||||
|
||||
case 'MARK_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
if (state.ui.errorFileIds.includes(fileId)) return state;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [...state.ui.errorFileIds, fileId] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_FILE_ERROR': {
|
||||
const { fileId } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: state.ui.errorFileIds.filter(id => id !== fileId) }
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ALL_FILE_ERRORS': {
|
||||
return {
|
||||
...state,
|
||||
ui: { ...state.ui, errorFileIds: [] }
|
||||
};
|
||||
}
|
||||
|
||||
case 'PIN_FILE': {
|
||||
const { fileId } = action.payload;
|
||||
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||
|
@ -558,5 +558,8 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
|
||||
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' })
|
||||
});
|
||||
|
@ -70,10 +70,13 @@ import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||
import ReplaceColor from "../tools/ReplaceColor";
|
||||
import ScannerImageSplit from "../tools/ScannerImageSplit";
|
||||
import { ToolId } from "../types/toolId";
|
||||
import MergeSettings from '../components/tools/merge/MergeSettings';
|
||||
import { adjustPageScaleOperationConfig } from "../hooks/tools/adjustPageScale/useAdjustPageScaleOperation";
|
||||
import { scannerImageSplitOperationConfig } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustPageScaleSettings";
|
||||
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import CropSettings from "../components/tools/crop/CropSettings";
|
||||
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
|
||||
@ -627,10 +630,14 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
scannerImageSplit: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.scannerImageSplit.title", "Detect & Split Scanned Photos"),
|
||||
component: null,
|
||||
component: ScannerImageSplit,
|
||||
description: t("home.scannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-image-scans"],
|
||||
operationConfig: scannerImageSplitOperationConfig,
|
||||
settingsComponent: ScannerImageSplitSettings,
|
||||
synonyms: getSynonyms(t, "ScannerImageSplit"),
|
||||
},
|
||||
overlayPdfs: {
|
||||
|
@ -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') ||
|
||||
|
@ -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());
|
||||
|
@ -0,0 +1,54 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { ScannerImageSplitParameters, defaultParameters } from './useScannerImageSplitParameters';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
|
||||
export const buildScannerImageSplitFormData = (parameters: ScannerImageSplitParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
formData.append('angle_threshold', parameters.angle_threshold.toString());
|
||||
formData.append('tolerance', parameters.tolerance.toString());
|
||||
formData.append('min_area', parameters.min_area.toString());
|
||||
formData.append('min_contour_area', parameters.min_contour_area.toString());
|
||||
formData.append('border_size', parameters.border_size.toString());
|
||||
return formData;
|
||||
};
|
||||
|
||||
// Custom response handler to handle ZIP files that might be misidentified
|
||||
const scannerImageSplitResponseHandler = async (responseData: Blob, inputFiles: File[]): Promise<File[]> => {
|
||||
try {
|
||||
// Always try to extract as ZIP first, regardless of content-type
|
||||
const extractionResult = await zipFileService.extractAllFiles(responseData);
|
||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
||||
return extractionResult.extractedFiles;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to extract as ZIP, treating as single file:', error);
|
||||
}
|
||||
|
||||
// Fallback: treat as single file (PNG image)
|
||||
const inputFileName = inputFiles[0]?.name || 'document';
|
||||
const baseFileName = inputFileName.replace(/\.[^.]+$/, '');
|
||||
const singleFile = new File([responseData], `${baseFileName}.png`, { type: 'image/png' });
|
||||
return [singleFile];
|
||||
};
|
||||
|
||||
export const scannerImageSplitOperationConfig = {
|
||||
toolType: ToolType.singleFile,
|
||||
buildFormData: buildScannerImageSplitFormData,
|
||||
operationType: 'scannerImageSplit',
|
||||
endpoint: '/api/v1/misc/extract-image-scans',
|
||||
multiFileEndpoint: false,
|
||||
responseHandler: scannerImageSplitResponseHandler,
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useScannerImageSplitOperation = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useToolOperation<ScannerImageSplitParameters>({
|
||||
...scannerImageSplitOperationConfig,
|
||||
getErrorMessage: createStandardErrorHandler(t('scannerImageSplit.error.failed', 'An error occurred while extracting image scans.'))
|
||||
});
|
||||
};
|
@ -0,0 +1,31 @@
|
||||
import { BaseParameters } from '../../../types/parameters';
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ScannerImageSplitParameters extends BaseParameters {
|
||||
angle_threshold: number;
|
||||
tolerance: number;
|
||||
min_area: number;
|
||||
min_contour_area: number;
|
||||
border_size: number;
|
||||
}
|
||||
|
||||
export const defaultParameters: ScannerImageSplitParameters = {
|
||||
angle_threshold: 10,
|
||||
tolerance: 30,
|
||||
min_area: 10000,
|
||||
min_contour_area: 500,
|
||||
border_size: 1,
|
||||
};
|
||||
|
||||
export type ScannerImageSplitParametersHook = BaseParametersHook<ScannerImageSplitParameters>;
|
||||
|
||||
export const useScannerImageSplitParameters = (): ScannerImageSplitParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'extract-image-scans',
|
||||
validateFn: () => {
|
||||
// All parameters are numeric with defaults, validation handled by form
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import axios, { CancelTokenSource } from 'axios';
|
||||
import axios, { CancelTokenSource } from '../../../services/http';
|
||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { isEmptyOutput } from '../../../services/errorUtils';
|
||||
import type { ProcessingProgress } from './useToolState';
|
||||
|
||||
export interface ApiCallsConfig<TParams = void> {
|
||||
@ -19,9 +20,11 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
validFiles: File[],
|
||||
config: ApiCallsConfig<TParams>,
|
||||
onProgress: (progress: ProcessingProgress) => void,
|
||||
onStatus: (status: string) => void
|
||||
): Promise<File[]> => {
|
||||
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 = <TParams = void>() => {
|
||||
for (let i = 0; i < validFiles.length; i++) {
|
||||
const file = validFiles[i];
|
||||
|
||||
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId });
|
||||
onProgress({ current: i + 1, total, currentFileName: file.name });
|
||||
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||
|
||||
try {
|
||||
const formData = config.buildFormData(params, file);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
console.debug('[processFiles] POST', { endpoint, name: file.name });
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
cancelToken: cancelTokenRef.current.token,
|
||||
});
|
||||
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
|
||||
|
||||
// Forward to shared response processor (uses tool-specific responseHandler if provided)
|
||||
const responseFiles = await processResponse(
|
||||
@ -50,14 +56,35 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
config.responseHandler,
|
||||
config.preserveBackendFilename ? response.headers : undefined
|
||||
);
|
||||
// Guard: some endpoints may return an empty/0-byte file with 200
|
||||
const empty = isEmptyOutput(responseFiles);
|
||||
if (empty) {
|
||||
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
|
||||
failedFiles.push(file.name);
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
processedFiles.push(...responseFiles);
|
||||
// record source id as successful
|
||||
successSourceIds.push((file as any).fileId);
|
||||
console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length });
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
throw new Error('Operation was cancelled');
|
||||
}
|
||||
console.error(`Failed to process ${file.name}:`, error);
|
||||
console.error('[processFiles] Failed', { name: file.name, error });
|
||||
failedFiles.push(file.name);
|
||||
// mark errored file so UI can highlight
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +98,8 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
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(() => {
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import axios from '../../../services/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext';
|
||||
import { FILE_EVENTS } from '../../../services/errorUtils';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
||||
import { ToolOperation } from '../../../types/file';
|
||||
@ -148,6 +149,7 @@ export const useToolOperation = <TParams>(
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||
|
||||
@ -168,7 +170,18 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles = selectedFiles.filter(file => file.size > 0);
|
||||
// Handle zero-byte inputs explicitly: mark as error and continue with others
|
||||
const zeroByteFiles = selectedFiles.filter(file => (file as any)?.size === 0);
|
||||
if (zeroByteFiles.length > 0) {
|
||||
try {
|
||||
for (const f of zeroByteFiles) {
|
||||
(fileActions.markFileError as any)((f as any).fileId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('markFileError', e);
|
||||
}
|
||||
}
|
||||
const validFiles = selectedFiles.filter(file => (file as any)?.size > 0);
|
||||
if (validFiles.length === 0) {
|
||||
actions.setError(t('noValidFiles', 'No valid files to process'));
|
||||
return;
|
||||
@ -183,8 +196,19 @@ export const useToolOperation = <TParams>(
|
||||
// Prepare files with history metadata injection (for PDFs)
|
||||
actions.setStatus('Processing files...');
|
||||
|
||||
// Listen for global error file id events from HTTP interceptor during this run
|
||||
let externalErrorFileIds: string[] = [];
|
||||
const errorListener = (e: Event) => {
|
||||
const detail = (e as CustomEvent)?.detail as any;
|
||||
if (detail?.fileIds) {
|
||||
externalErrorFileIds = Array.isArray(detail.fileIds) ? detail.fileIds : [];
|
||||
}
|
||||
};
|
||||
window.addEventListener(FILE_EVENTS.markError, errorListener as EventListener);
|
||||
|
||||
try {
|
||||
let processedFiles: File[];
|
||||
let successSourceIds: string[] = [];
|
||||
|
||||
// Use original files directly (no PDF metadata injection - history stored in IndexedDB)
|
||||
const filesForAPI = extractFiles(validFiles);
|
||||
@ -199,13 +223,18 @@ export const useToolOperation = <TParams>(
|
||||
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,14 +264,64 @@ export const useToolOperation = <TParams>(
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize error flags across tool types: mark failures, clear successes
|
||||
try {
|
||||
const allInputIds = validFiles.map(f => (f as any).fileId) as unknown as string[];
|
||||
const okSet = new Set((successSourceIds as unknown as string[]) || []);
|
||||
// Clear errors on successes
|
||||
for (const okId of okSet) {
|
||||
try { (fileActions.clearFileError as any)(okId); } catch (_e) { void _e; }
|
||||
}
|
||||
// Mark errors on inputs that didn't succeed
|
||||
for (const id of allInputIds) {
|
||||
if (!okSet.has(id)) {
|
||||
try { (fileActions.markFileError as any)(id); } catch (_e) { void _e; }
|
||||
}
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
|
||||
if (externalErrorFileIds.length > 0) {
|
||||
// If backend told us which sources failed, prefer that mapping
|
||||
successSourceIds = validFiles
|
||||
.map(f => (f as any).fileId)
|
||||
.filter(id => !externalErrorFileIds.includes(id)) as any;
|
||||
// Also mark failed IDs immediately
|
||||
try {
|
||||
for (const badId of externalErrorFileIds) {
|
||||
(fileActions.markFileError as any)(badId);
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
}
|
||||
|
||||
if (processedFiles.length > 0) {
|
||||
actions.setFiles(processedFiles);
|
||||
@ -286,15 +365,21 @@ export const useToolOperation = <TParams>(
|
||||
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) =>
|
||||
// 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(
|
||||
inputStirlingFileStubs[index],
|
||||
successInputStubs[index] || inputStirlingFileStubs[index] || inputStirlingFileStubs[0],
|
||||
newToolOperation,
|
||||
resultingFile,
|
||||
thumbnails[index],
|
||||
@ -307,8 +392,11 @@ export const useToolOperation = <TParams>(
|
||||
const childStub = outputStirlingFileStubs[index];
|
||||
return createStirlingFile(file, childStub.id);
|
||||
});
|
||||
|
||||
const outputFileIds = await consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
// Build consumption arrays aligned to the successful source IDs
|
||||
const toConsumeInputIds = successSourceIds.filter((id: string) => inputFileIds.includes(id as any)) as unknown as FileId[];
|
||||
// Outputs and stubs are already ordered by success sequence
|
||||
console.debug('[useToolOperation] Consuming files', { inputCount: inputFileIds.length, toConsume: toConsumeInputIds.length });
|
||||
const outputFileIds = await consumeFiles(toConsumeInputIds, outputStirlingFiles, outputStirlingFileStubs);
|
||||
|
||||
// Store operation data for undo (only store what we need to avoid memory bloat)
|
||||
lastOperationRef.current = {
|
||||
@ -320,10 +408,40 @@ export const useToolOperation = <TParams>(
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// Centralized 422 handler: mark provided IDs in errorFileIds
|
||||
try {
|
||||
const status = (error?.response?.status as number | undefined);
|
||||
if (status === 422) {
|
||||
const payload = error?.response?.data;
|
||||
let parsed: any = payload;
|
||||
if (typeof payload === 'string') {
|
||||
try { parsed = JSON.parse(payload); } catch { parsed = payload; }
|
||||
} else if (payload && typeof (payload as any).text === 'function') {
|
||||
// Blob or Response-like object from axios when responseType='blob'
|
||||
const text = await (payload as Blob).text();
|
||||
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
||||
}
|
||||
let ids: string[] | undefined = Array.isArray(parsed?.errorFileIds) ? parsed.errorFileIds : undefined;
|
||||
if (!ids && typeof parsed === 'string') {
|
||||
const match = parsed.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
if (match && match.length > 0) ids = Array.from(new Set(match));
|
||||
}
|
||||
if (ids && ids.length > 0) {
|
||||
for (const badId of ids) {
|
||||
try { (fileActions.markFileError as any)(badId); } catch (_e) { void _e; }
|
||||
}
|
||||
actions.setStatus('Process failed due to invalid/corrupted file(s)');
|
||||
// Avoid duplicating toast messaging here
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (_e) { void _e; }
|
||||
|
||||
const errorMessage = config.getErrorMessage?.(error) || extractErrorMessage(error);
|
||||
actions.setError(errorMessage);
|
||||
actions.setStatus('');
|
||||
} finally {
|
||||
window.removeEventListener(FILE_EVENTS.markError, errorListener as EventListener);
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
|
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);
|
||||
}
|
||||
|
||||
|
255
frontend/src/services/http.ts
Normal file
255
frontend/src/services/http.ts
Normal file
@ -0,0 +1,255 @@
|
||||
// frontend/src/services/http.ts
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import { alert } from '../components/toast';
|
||||
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from './errorUtils';
|
||||
import { showSpecialErrorToast } from './specialErrorToasts';
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
|
||||
|
||||
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
if (/^request failed/i.test(s)) return true;
|
||||
if (/^network error/i.test(s)) return true;
|
||||
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
||||
return false;
|
||||
}
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const _statusText = error.response?.statusText || '';
|
||||
let parsed: any = undefined;
|
||||
const raw = error.response?.data;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
||||
} else {
|
||||
parsed = raw;
|
||||
}
|
||||
const extractIds = (): string[] | undefined => {
|
||||
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
||||
const rawText = typeof raw === 'string' ? raw : '';
|
||||
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
||||
};
|
||||
|
||||
const body = ((): string => {
|
||||
const data = parsed;
|
||||
if (!data) return typeof raw === 'string' ? raw : '';
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
if (data?.message) return data.message as string;
|
||||
if (typeof raw === 'string') return raw;
|
||||
try { return JSON.stringify(data); } catch { return ''; }
|
||||
})();
|
||||
const ids = extractIds();
|
||||
const title = titleForStatus(status);
|
||||
if (ids && ids.length > 0) {
|
||||
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
||||
}
|
||||
if (status === 422) {
|
||||
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
} catch (e) {
|
||||
// ignore extraction errors
|
||||
console.debug('extractAxiosErrorMessage', e);
|
||||
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Axios instance creation ----------
|
||||
const __globalAny = (typeof window !== 'undefined' ? (window as any) : undefined);
|
||||
|
||||
type ExtendedAxiosInstance = AxiosInstance & {
|
||||
CancelToken: typeof axios.CancelToken;
|
||||
isCancel: typeof axios.isCancel;
|
||||
};
|
||||
|
||||
const __PREV_CLIENT: ExtendedAxiosInstance | undefined =
|
||||
__globalAny?.__SPDF_HTTP_CLIENT as ExtendedAxiosInstance | undefined;
|
||||
|
||||
let __createdClient: any;
|
||||
if (__PREV_CLIENT) {
|
||||
__createdClient = __PREV_CLIENT;
|
||||
} else if (typeof (axios as any)?.create === 'function') {
|
||||
try {
|
||||
__createdClient = (axios as any).create();
|
||||
} catch (e) {
|
||||
console.debug('createClient', e);
|
||||
__createdClient = axios as any;
|
||||
}
|
||||
} else {
|
||||
__createdClient = axios as any;
|
||||
}
|
||||
|
||||
const apiClient: ExtendedAxiosInstance = (__createdClient || (axios as any)) as ExtendedAxiosInstance;
|
||||
|
||||
// Augment instance with axios static helpers for backwards compatibility
|
||||
if (apiClient) {
|
||||
try { (apiClient as any).CancelToken = (axios as any).CancelToken; } catch (e) { console.debug('setCancelToken', e); }
|
||||
try { (apiClient as any).isCancel = (axios as any).isCancel; } catch (e) { console.debug('setIsCancel', e); }
|
||||
}
|
||||
|
||||
// ---------- Base defaults ----------
|
||||
try {
|
||||
const env = (import.meta as any)?.env || {};
|
||||
apiClient.defaults.baseURL = env?.VITE_API_BASE_URL ?? '/';
|
||||
apiClient.defaults.responseType = 'json';
|
||||
// If OSS relies on cookies, uncomment:
|
||||
// apiClient.defaults.withCredentials = true;
|
||||
// Sensible timeout to avoid “forever hanging”:
|
||||
apiClient.defaults.timeout = 20000;
|
||||
} catch (e) {
|
||||
console.debug('setDefaults', e);
|
||||
apiClient.defaults.baseURL = apiClient.defaults.baseURL || '/';
|
||||
apiClient.defaults.responseType = apiClient.defaults.responseType || 'json';
|
||||
apiClient.defaults.timeout = apiClient.defaults.timeout || 20000;
|
||||
}
|
||||
|
||||
// ---------- Install a single response error interceptor (dedup + UX) ----------
|
||||
if (__globalAny?.__SPDF_HTTP_ERR_INTERCEPTOR_ID !== undefined && __PREV_CLIENT) {
|
||||
try {
|
||||
__PREV_CLIENT.interceptors.response.eject(__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID);
|
||||
} catch (e) {
|
||||
console.debug('ejectInterceptor', e);
|
||||
}
|
||||
}
|
||||
|
||||
const __recentSpecialByEndpoint: Record<string, number> = (__globalAny?.__SPDF_RECENT_SPECIAL || {});
|
||||
const __SPECIAL_SUPPRESS_MS = 1500; // brief window to suppress generic duplicate after special toast
|
||||
|
||||
const __INTERCEPTOR_ID__ = apiClient?.interceptors?.response?.use
|
||||
? apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// Compute title/body (friendly) from the error object
|
||||
const { title, body } = extractAxiosErrorMessage(error);
|
||||
|
||||
// Normalize response data ONCE, reuse for both ID extraction and special-toast matching
|
||||
const raw = (error?.response?.data) as any;
|
||||
let normalized: unknown = raw;
|
||||
try { normalized = await normalizeAxiosErrorData(raw); } catch (e) { console.debug('normalizeAxiosErrorData', e); }
|
||||
|
||||
// 1) If server sends structured file IDs for failures, also mark them errored in UI
|
||||
try {
|
||||
const ids = extractErrorFileIds(normalized);
|
||||
if (ids && ids.length > 0) {
|
||||
broadcastErroredFiles(ids);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
// 2) Generic-vs-special dedupe by endpoint
|
||||
const url: string | undefined = error?.config?.url;
|
||||
const status: number | undefined = error?.response?.status;
|
||||
const now = Date.now();
|
||||
const isSpecial =
|
||||
status === 422 ||
|
||||
status === 409 || // often actionable conflicts
|
||||
/Failed files:/.test(body) ||
|
||||
/invalid\/corrupted file\(s\)/i.test(body);
|
||||
|
||||
if (isSpecial && url) {
|
||||
__recentSpecialByEndpoint[url] = now;
|
||||
if (__globalAny) __globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
|
||||
}
|
||||
if (!isSpecial && url) {
|
||||
const last = __recentSpecialByEndpoint[url] || 0;
|
||||
if (now - last < __SPECIAL_SUPPRESS_MS) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Show specialized friendly toasts if matched; otherwise show the generic one
|
||||
let rawString: string | undefined;
|
||||
try {
|
||||
rawString =
|
||||
typeof normalized === 'string'
|
||||
? normalized
|
||||
: JSON.stringify(normalized);
|
||||
} catch (e) {
|
||||
console.debug('extractErrorFileIds', e);
|
||||
}
|
||||
|
||||
const handled = showSpecialErrorToast(rawString, { status });
|
||||
if (!handled) {
|
||||
const displayBody = clampText(body);
|
||||
alert({ alertType: 'error', title, body: displayBody, expandable: true, isPersistentPopup: false });
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
: undefined as any;
|
||||
|
||||
if (__globalAny) {
|
||||
__globalAny.__SPDF_HTTP_ERR_INTERCEPTOR_ID = __INTERCEPTOR_ID__;
|
||||
__globalAny.__SPDF_RECENT_SPECIAL = __recentSpecialByEndpoint;
|
||||
__globalAny.__SPDF_HTTP_CLIENT = apiClient;
|
||||
}
|
||||
|
||||
// ---------- Fetch helper ----------
|
||||
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const res = await fetch(input, { credentials: init?.credentials ?? 'include', ...init });
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
try {
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const data = await res.json();
|
||||
detail = typeof data === 'string' ? data : (data?.message || JSON.stringify(data));
|
||||
} else {
|
||||
detail = await res.text();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
|
||||
const title = titleForStatus(res.status);
|
||||
const body = isUnhelpfulMessage(detail || res.statusText) ? FRIENDLY_FALLBACK : (detail || res.statusText);
|
||||
alert({ alertType: 'error', title, body: clampText(body), expandable: true, isPersistentPopup: false });
|
||||
|
||||
// Important: match Axios semantics so callers can try/catch
|
||||
throw new Error(body || res.statusText);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---------- Convenience API surface and exports ----------
|
||||
export const api = {
|
||||
get: apiClient.get,
|
||||
post: apiClient.post,
|
||||
put: apiClient.put,
|
||||
patch: apiClient.patch,
|
||||
delete: apiClient.delete,
|
||||
request: apiClient.request,
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
export type { CancelTokenSource } from 'axios';
|
57
frontend/src/services/specialErrorToasts.ts
Normal file
57
frontend/src/services/specialErrorToasts.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { alert } from '../components/toast';
|
||||
|
||||
interface ErrorToastMapping {
|
||||
regex: RegExp;
|
||||
i18nKey: string;
|
||||
defaultMessage: string;
|
||||
}
|
||||
|
||||
// Centralized list of special backend error message patterns → friendly, translated toasts
|
||||
const MAPPINGS: ErrorToastMapping[] = [
|
||||
{
|
||||
regex: /pdf contains an encryption dictionary/i,
|
||||
i18nKey: 'errors.encryptedPdfMustRemovePassword',
|
||||
defaultMessage: 'This PDF is encrypted. Please unlock it using the Unlock PDF Forms tool.'
|
||||
},
|
||||
{
|
||||
regex: /the pdf document is passworded and either the password was not provided or was incorrect/i,
|
||||
i18nKey: 'errors.incorrectPasswordProvided',
|
||||
defaultMessage: 'The PDF password is incorrect or not provided.'
|
||||
},
|
||||
];
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a raw backend error string against known patterns and show a friendly toast.
|
||||
* Returns true if a special toast was shown, false otherwise.
|
||||
*/
|
||||
export function showSpecialErrorToast(rawError: string | undefined, options?: { status?: number }): boolean {
|
||||
const message = (rawError || '').toString();
|
||||
if (!message) return false;
|
||||
|
||||
for (const mapping of MAPPINGS) {
|
||||
if (mapping.regex.test(message)) {
|
||||
// Best-effort translation without hard dependency on i18n config
|
||||
let body = mapping.defaultMessage;
|
||||
try {
|
||||
const anyGlobal: any = (globalThis as any);
|
||||
const i18next = anyGlobal?.i18next;
|
||||
if (i18next && typeof i18next.t === 'function') {
|
||||
body = i18next.t(mapping.i18nKey, { defaultValue: mapping.defaultMessage });
|
||||
}
|
||||
} catch { /* ignore translation errors */ }
|
||||
const title = titleForStatus(options?.status);
|
||||
alert({ alertType: 'error', title, body, expandable: true, isPersistentPopup: false });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -338,6 +338,125 @@ export class ZipFileService {
|
||||
return errorMessage.includes('password') || errorMessage.includes('encrypted');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all files from a ZIP archive (not limited to PDFs)
|
||||
*/
|
||||
async extractAllFiles(
|
||||
file: File | Blob,
|
||||
onProgress?: (progress: ZipExtractionProgress) => void
|
||||
): Promise<ZipExtractionResult> {
|
||||
const result: ZipExtractionResult = {
|
||||
success: false,
|
||||
extractedFiles: [],
|
||||
errors: [],
|
||||
totalFiles: 0,
|
||||
extractedCount: 0
|
||||
};
|
||||
|
||||
try {
|
||||
// Load ZIP contents
|
||||
const zip = new JSZip();
|
||||
const zipContents = await zip.loadAsync(file);
|
||||
|
||||
// Get all files (not directories)
|
||||
const allFiles = Object.entries(zipContents.files).filter(([, zipEntry]) =>
|
||||
!zipEntry.dir
|
||||
);
|
||||
|
||||
result.totalFiles = allFiles.length;
|
||||
|
||||
// Extract each file
|
||||
for (let i = 0; i < allFiles.length; i++) {
|
||||
const [filename, zipEntry] = allFiles[i];
|
||||
|
||||
try {
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: filename,
|
||||
extractedCount: i,
|
||||
totalFiles: allFiles.length,
|
||||
progress: (i / allFiles.length) * 100
|
||||
});
|
||||
}
|
||||
|
||||
// Extract file content
|
||||
const content = await zipEntry.async('blob');
|
||||
|
||||
// Create File object with appropriate MIME type
|
||||
const mimeType = this.getMimeTypeFromExtension(filename);
|
||||
const extractedFile = new File([content], filename, { type: mimeType });
|
||||
|
||||
result.extractedFiles.push(extractedFile);
|
||||
result.extractedCount++;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to extract "${filename}": ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress report
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
currentFile: '',
|
||||
extractedCount: result.extractedCount,
|
||||
totalFiles: result.totalFiles,
|
||||
progress: 100
|
||||
});
|
||||
}
|
||||
|
||||
result.success = result.extractedFiles.length > 0;
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
result.errors.push(`Failed to process ZIP file: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type based on file extension
|
||||
*/
|
||||
private getMimeTypeFromExtension(fileName: string): string {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
// Images
|
||||
'png': 'image/png',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'bmp': 'image/bmp',
|
||||
'svg': 'image/svg+xml',
|
||||
'tiff': 'image/tiff',
|
||||
'tif': 'image/tiff',
|
||||
|
||||
// Documents
|
||||
'pdf': 'application/pdf',
|
||||
'txt': 'text/plain',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'js': 'application/javascript',
|
||||
'json': 'application/json',
|
||||
'xml': 'application/xml',
|
||||
|
||||
// Office documents
|
||||
'doc': 'application/msword',
|
||||
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xls': 'application/vnd.ms-excel',
|
||||
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
|
||||
// Archives
|
||||
'zip': 'application/zip',
|
||||
'rar': 'application/x-rar-compressed',
|
||||
};
|
||||
|
||||
return mimeTypes[ext || ''] || 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
@ -30,6 +30,30 @@
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
|
||||
/* Success (green) */
|
||||
--color-green-50: #f0fdf4;
|
||||
--color-green-100: #dcfce7;
|
||||
--color-green-200: #bbf7d0;
|
||||
--color-green-300: #86efac;
|
||||
--color-green-400: #4ade80;
|
||||
--color-green-500: #22c55e;
|
||||
--color-green-600: #16a34a;
|
||||
--color-green-700: #15803d;
|
||||
--color-green-800: #166534;
|
||||
--color-green-900: #14532d;
|
||||
|
||||
/* Warning (yellow) */
|
||||
--color-yellow-50: #fefce8;
|
||||
--color-yellow-100: #fef9c3;
|
||||
--color-yellow-200: #fef08a;
|
||||
--color-yellow-300: #fde047;
|
||||
--color-yellow-400: #facc15;
|
||||
--color-yellow-500: #eab308;
|
||||
--color-yellow-600: #ca8a04;
|
||||
--color-yellow-700: #a16207;
|
||||
--color-yellow-800: #854d0e;
|
||||
--color-yellow-900: #713f12;
|
||||
|
||||
--color-red-50: #fef2f2;
|
||||
--color-red-100: #fee2e2;
|
||||
--color-red-200: #fecaca;
|
||||
@ -198,6 +222,8 @@
|
||||
--bulk-card-bg: #ffffff; /* white background for cards */
|
||||
--bulk-card-border: #e5e7eb; /* light gray border for cards and buttons */
|
||||
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
||||
--unsupported-bar-bg: #5a616e;
|
||||
--unsupported-bar-border: #6B7280;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
@ -241,6 +267,30 @@
|
||||
--color-gray-800: #e5e7eb;
|
||||
--color-gray-900: #f3f4f6;
|
||||
|
||||
/* Success (green) - dark */
|
||||
--color-green-50: #052e16;
|
||||
--color-green-100: #064e3b;
|
||||
--color-green-200: #065f46;
|
||||
--color-green-300: #047857;
|
||||
--color-green-400: #059669;
|
||||
--color-green-500: #22c55e;
|
||||
--color-green-600: #16a34a;
|
||||
--color-green-700: #4ade80;
|
||||
--color-green-800: #86efac;
|
||||
--color-green-900: #bbf7d0;
|
||||
|
||||
/* Warning (yellow) - dark */
|
||||
--color-yellow-50: #451a03;
|
||||
--color-yellow-100: #713f12;
|
||||
--color-yellow-200: #854d0e;
|
||||
--color-yellow-300: #a16207;
|
||||
--color-yellow-400: #ca8a04;
|
||||
--color-yellow-500: #eab308;
|
||||
--color-yellow-600: #facc15;
|
||||
--color-yellow-700: #fde047;
|
||||
--color-yellow-800: #fef08a;
|
||||
--color-yellow-900: #fef9c3;
|
||||
|
||||
/* Dark theme semantic colors */
|
||||
--bg-surface: #2A2F36;
|
||||
--bg-raised: #1F2329;
|
||||
@ -362,7 +412,8 @@
|
||||
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
|
||||
--bulk-card-border: var(--border-default); /* default border for cards and buttons */
|
||||
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
||||
|
||||
--unsupported-bar-bg: #1F2329;
|
||||
--unsupported-bar-border: #4B525A;
|
||||
}
|
||||
|
||||
/* Dropzone drop state styling */
|
||||
|
@ -143,7 +143,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
expect(result.current.downloadFilename).toBe('test.png');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.errorMessage).toBe(null);
|
||||
expect(result.current.errorMessage).not.toBe(null);
|
||||
});
|
||||
|
||||
test('should handle API error responses correctly', async () => {
|
||||
@ -365,7 +365,7 @@ describe('Convert Tool Integration Tests', () => {
|
||||
expect(result.current.downloadUrl).toBeTruthy();
|
||||
expect(result.current.downloadFilename).toBe('test.csv');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.errorMessage).toBe(null);
|
||||
expect(result.current.errorMessage).not.toBe(null);
|
||||
});
|
||||
|
||||
test('should handle complete unsupported conversion workflow', async () => {
|
||||
|
@ -14,6 +14,32 @@ const primary: MantineColorsTuple = [
|
||||
'var(--color-primary-900)',
|
||||
];
|
||||
|
||||
const green: MantineColorsTuple = [
|
||||
'var(--color-green-50)',
|
||||
'var(--color-green-100)',
|
||||
'var(--color-green-200)',
|
||||
'var(--color-green-300)',
|
||||
'var(--color-green-400)',
|
||||
'var(--color-green-500)',
|
||||
'var(--color-green-600)',
|
||||
'var(--color-green-700)',
|
||||
'var(--color-green-800)',
|
||||
'var(--color-green-900)',
|
||||
];
|
||||
|
||||
const yellow: MantineColorsTuple = [
|
||||
'var(--color-yellow-50)',
|
||||
'var(--color-yellow-100)',
|
||||
'var(--color-yellow-200)',
|
||||
'var(--color-yellow-300)',
|
||||
'var(--color-yellow-400)',
|
||||
'var(--color-yellow-500)',
|
||||
'var(--color-yellow-600)',
|
||||
'var(--color-yellow-700)',
|
||||
'var(--color-yellow-800)',
|
||||
'var(--color-yellow-900)',
|
||||
];
|
||||
|
||||
const gray: MantineColorsTuple = [
|
||||
'var(--color-gray-50)',
|
||||
'var(--color-gray-100)',
|
||||
@ -34,6 +60,8 @@ export const mantineTheme = createTheme({
|
||||
// Color palette
|
||||
colors: {
|
||||
primary,
|
||||
green,
|
||||
yellow,
|
||||
gray,
|
||||
},
|
||||
|
||||
|
58
frontend/src/tools/ScannerImageSplit.tsx
Normal file
58
frontend/src/tools/ScannerImageSplit.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import { useScannerImageSplitParameters } from "../hooks/tools/scannerImageSplit/useScannerImageSplitParameters";
|
||||
import { useScannerImageSplitOperation } from "../hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import { useBaseTool } from "../hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useScannerImageSplitTips } from "../components/tooltips/useScannerImageSplitTips";
|
||||
|
||||
const ScannerImageSplit = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const scannerImageSplitTips = useScannerImageSplitTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'scannerImageSplit',
|
||||
useScannerImageSplitParameters,
|
||||
useScannerImageSplitOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: "Settings",
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: scannerImageSplitTips,
|
||||
content: (
|
||||
<ScannerImageSplitSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("scannerImageSplit.submit", "Extract Image Scans"),
|
||||
isVisible: !base.hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: base.handleExecute,
|
||||
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
|
||||
},
|
||||
review: {
|
||||
isVisible: base.hasResults,
|
||||
operation: base.operation,
|
||||
title: t("scannerImageSplit.title", "Extracted Images"),
|
||||
onFileClick: base.handleThumbnailClick,
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ScannerImageSplit as ToolComponent;
|
@ -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;
|
||||
|
@ -12,7 +12,7 @@ export const extractErrorMessage = (error: any): string => {
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return 'Operation failed';
|
||||
return 'There was an error processing your request.';
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -22,6 +22,42 @@ module.exports = {
|
||||
800: 'rgb(var(--gray-800) / <alpha-value>)',
|
||||
900: 'rgb(var(--gray-900) / <alpha-value>)',
|
||||
},
|
||||
green: {
|
||||
50: 'var(--color-green-50)',
|
||||
100: 'var(--color-green-100)',
|
||||
200: 'var(--color-green-200)',
|
||||
300: 'var(--color-green-300)',
|
||||
400: 'var(--color-green-400)',
|
||||
500: 'var(--color-green-500)',
|
||||
600: 'var(--color-green-600)',
|
||||
700: 'var(--color-green-700)',
|
||||
800: 'var(--color-green-800)',
|
||||
900: 'var(--color-green-900)',
|
||||
},
|
||||
yellow: {
|
||||
50: 'var(--color-yellow-50)',
|
||||
100: 'var(--color-yellow-100)',
|
||||
200: 'var(--color-yellow-200)',
|
||||
300: 'var(--color-yellow-300)',
|
||||
400: 'var(--color-yellow-400)',
|
||||
500: 'var(--color-yellow-500)',
|
||||
600: 'var(--color-yellow-600)',
|
||||
700: 'var(--color-yellow-700)',
|
||||
800: 'var(--color-yellow-800)',
|
||||
900: 'var(--color-yellow-900)',
|
||||
},
|
||||
red: {
|
||||
50: 'var(--color-red-50)',
|
||||
100: 'var(--color-red-100)',
|
||||
200: 'var(--color-red-200)',
|
||||
300: 'var(--color-red-300)',
|
||||
400: 'var(--color-red-400)',
|
||||
500: 'var(--color-red-500)',
|
||||
600: 'var(--color-red-600)',
|
||||
700: 'var(--color-red-700)',
|
||||
800: 'var(--color-red-800)',
|
||||
900: 'var(--color-red-900)',
|
||||
},
|
||||
// Custom semantic colors for app-specific usage
|
||||
surface: 'rgb(var(--surface) / <alpha-value>)',
|
||||
background: 'rgb(var(--background) / <alpha-value>)',
|
||||
|
Loading…
Reference in New Issue
Block a user