photo scan

This commit is contained in:
Anthony Stirling 2025-12-16 22:10:04 +00:00
parent 63550e0c7d
commit 1beed8dc57
18 changed files with 2816 additions and 232 deletions

1
.gitignore vendored
View File

@ -43,6 +43,7 @@ app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
app/core/src/main/resources/static/pdfium/
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle

View File

@ -0,0 +1,291 @@
package stirling.software.common.service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
/**
* Service for handling mobile scanner file uploads and temporary storage. Files are stored
* temporarily and automatically cleaned up after 10 minutes or upon retrieval.
*/
@Service
@Slf4j
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
public MobileScannerService() throws IOException {
// Create temp directory for mobile scanner uploads
this.tempDirectory =
Paths.get(System.getProperty("java.io.tmpdir"), "stirling-mobile-scanner");
Files.createDirectories(tempDirectory);
log.info("Mobile scanner temp directory: {}", tempDirectory);
}
/**
* Stores uploaded files for a session
*
* @param sessionId Unique session identifier
* @param files Files to upload
* @throws IOException If file storage fails
*/
public void uploadFiles(String sessionId, List<MultipartFile> files) throws IOException {
validateSessionId(sessionId);
SessionData session =
activeSessions.computeIfAbsent(sessionId, id -> new SessionData(sessionId));
// Create session directory
Path sessionDir = tempDirectory.resolve(sessionId);
Files.createDirectories(sessionDir);
// Save each file
for (MultipartFile file : files) {
if (file.isEmpty()) {
continue;
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
originalFilename = "upload-" + System.currentTimeMillis();
}
// Sanitize filename
String safeFilename = sanitizeFilename(originalFilename);
Path filePath = sessionDir.resolve(safeFilename);
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
: "";
safeFilename = nameWithoutExt + "-" + counter + ext;
filePath = sessionDir.resolve(safeFilename);
counter++;
}
file.transferTo(filePath);
session.addFile(new FileMetadata(safeFilename, file.getSize(), file.getContentType()));
log.info(
"Uploaded file for session {}: {} ({} bytes)",
sessionId,
safeFilename,
file.getSize());
}
session.updateLastAccess();
}
/**
* Retrieves file metadata for a session
*
* @param sessionId Session identifier
* @return List of file metadata, or empty list if session doesn't exist
*/
public List<FileMetadata> getSessionFiles(String sessionId) {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
return List.of();
}
session.updateLastAccess();
return new ArrayList<>(session.getFiles());
}
/**
* Retrieves actual file data for download
*
* @param sessionId Session identifier
* @param filename Filename to retrieve
* @return File path
* @throws IOException If file not found or session doesn't exist
*/
public Path getFile(String sessionId, String filename) throws IOException {
SessionData session = activeSessions.get(sessionId);
if (session == null) {
throw new IOException("Session not found: " + sessionId);
}
Path filePath = tempDirectory.resolve(sessionId).resolve(filename);
if (!Files.exists(filePath)) {
throw new IOException("File not found: " + filename);
}
session.updateLastAccess();
session.markFileAsDownloaded(filename);
return filePath;
}
/**
* Deletes a file after it has been served to the client. Should be called after successful
* download.
*
* @param sessionId Session identifier
* @param filename Filename to delete
*/
public void deleteFileAfterDownload(String sessionId, String filename) {
try {
Path filePath = tempDirectory.resolve(sessionId).resolve(filename);
Files.deleteIfExists(filePath);
log.info("Deleted file after download: {}/{}", sessionId, filename);
// Check if all files have been downloaded - if so, delete the entire session
SessionData session = activeSessions.get(sessionId);
if (session != null && session.allFilesDownloaded()) {
deleteSession(sessionId);
log.info("All files downloaded - deleted session: {}", sessionId);
}
} catch (IOException e) {
log.warn("Failed to delete file after download: {}/{}", sessionId, filename, e);
}
}
/**
* Deletes a session and all its files
*
* @param sessionId Session to delete
*/
public void deleteSession(String sessionId) {
SessionData session = activeSessions.remove(sessionId);
if (session != null) {
try {
Path sessionDir = tempDirectory.resolve(sessionId);
if (Files.exists(sessionDir)) {
// Delete all files in session directory
Files.walk(sessionDir)
.sorted(
(a, b) ->
-a.compareTo(b)) // Reverse order to delete files before
// directory
.forEach(
path -> {
try {
Files.deleteIfExists(path);
} catch (IOException e) {
log.warn("Failed to delete file: {}", path, e);
}
});
}
log.info("Deleted session: {}", sessionId);
} catch (IOException e) {
log.error("Error deleting session directory: {}", sessionId, e);
}
}
}
/** Scheduled cleanup of expired sessions (runs every 5 minutes) */
@Scheduled(fixedRate = 5 * 60 * 1000)
public void cleanupExpiredSessions() {
long now = System.currentTimeMillis();
List<String> expiredSessions = new ArrayList<>();
activeSessions.forEach(
(sessionId, session) -> {
if (now - session.getLastAccessTime() > SESSION_TIMEOUT_MS) {
expiredSessions.add(sessionId);
}
});
if (!expiredSessions.isEmpty()) {
log.info("Cleaning up {} expired mobile scanner sessions", expiredSessions.size());
expiredSessions.forEach(this::deleteSession);
}
}
private void validateSessionId(String sessionId) {
if (sessionId == null || sessionId.isBlank()) {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
return filename.replaceAll("[^a-zA-Z0-9._-]", "_");
}
/** File metadata for client */
public static class FileMetadata {
private final String filename;
private final long size;
private final String contentType;
public FileMetadata(String filename, long size, String contentType) {
this.filename = filename;
this.size = size;
this.contentType = contentType;
}
public String getFilename() {
return filename;
}
public long getSize() {
return size;
}
public String getContentType() {
return contentType;
}
}
/** Session data tracking */
private static class SessionData {
private final String sessionId;
private final List<FileMetadata> files = new ArrayList<>();
private final Map<String, Boolean> downloadedFiles = new HashMap<>();
private final long createdAt;
private long lastAccessTime;
public SessionData(String sessionId) {
this.sessionId = sessionId;
this.createdAt = System.currentTimeMillis();
this.lastAccessTime = createdAt;
}
public void addFile(FileMetadata file) {
files.add(file);
downloadedFiles.put(file.getFilename(), false);
}
public List<FileMetadata> getFiles() {
return files;
}
public void markFileAsDownloaded(String filename) {
downloadedFiles.put(filename, true);
}
public boolean allFilesDownloaded() {
return !downloadedFiles.isEmpty()
&& downloadedFiles.values().stream().allMatch(downloaded -> downloaded);
}
public void updateLastAccess() {
this.lastAccessTime = System.currentTimeMillis();
}
public long getLastAccessTime() {
return lastAccessTime;
}
}
}

View File

@ -52,6 +52,11 @@ public class RequestUriUtils {
return true;
}
// Mobile scanner page for QR code-based file uploads (peer-to-peer, no backend auth needed)
if (normalizedUri.startsWith("/mobile-scanner")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
@ -168,6 +173,8 @@ public class RequestUriUtils {
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
}

View File

@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
"principal",
"startDate",
"endDate",
"async");
"async",
"session");
@Override
public boolean preHandle(

View File

@ -71,6 +71,10 @@ public class ConfigController {
configData.put("contextPath", appConfig.getContextPath());
configData.put("serverPort", appConfig.getServerPort());
// Add frontendUrl for mobile scanner QR codes
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());

View File

@ -0,0 +1,219 @@
package stirling.software.SPDF.controller.api.misc;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.MobileScannerService;
import stirling.software.common.service.MobileScannerService.FileMetadata;
/**
* REST controller for mobile scanner functionality. Allows mobile devices to upload scanned images
* that can be retrieved by desktop clients via a session-based system. No authentication required
* for peer-to-peer scanning workflow.
*/
@RestController
@RequestMapping("/api/v1/mobile-scanner")
@Tag(
name = "Mobile Scanner",
description =
"Endpoints for mobile-to-desktop file transfer via QR code scanning. "
+ "Files are temporarily stored and automatically cleaned up after 10 minutes.")
@Hidden
@Slf4j
public class MobileScannerController {
private final MobileScannerService mobileScannerService;
public MobileScannerController(MobileScannerService mobileScannerService) {
this.mobileScannerService = mobileScannerService;
}
/**
* Upload files from mobile device
*
* @param sessionId Unique session identifier from QR code
* @param files Files to upload
* @return Upload status
*/
@PostMapping("/upload/{sessionId}")
@Operation(
summary = "Upload scanned files from mobile device",
description = "Mobile devices upload scanned images to a temporary session")
@ApiResponse(
responseCode = "200",
description = "Files uploaded successfully",
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
@ApiResponse(responseCode = "500", description = "Upload failed")
public ResponseEntity<Map<String, Object>> uploadFiles(
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
String sessionId,
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
List<MultipartFile> files) {
try {
if (files == null || files.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
}
mobileScannerService.uploadFiles(sessionId, files);
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("sessionId", sessionId);
response.put("filesUploaded", files.size());
response.put("message", "Files uploaded successfully");
log.info("Mobile scanner upload: session={}, files={}", sessionId, files.size());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid mobile scanner upload request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (IOException e) {
log.error("Failed to upload files for session: {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to save files"));
}
}
/**
* Get list of uploaded files for a session
*
* @param sessionId Session identifier
* @return List of file metadata
*/
@GetMapping("/files/{sessionId}")
@Operation(
summary = "Get uploaded files for a session",
description = "Desktop clients poll this endpoint to check for new uploads")
@ApiResponse(
responseCode = "200",
description = "File list retrieved",
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
public ResponseEntity<Map<String, Object>> getSessionFiles(
@Parameter(description = "Session ID", required = true) @PathVariable
String sessionId) {
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
Map<String, Object> response = new HashMap<>();
response.put("sessionId", sessionId);
response.put("files", files);
response.put("count", files.size());
return ResponseEntity.ok(response);
}
/**
* Download a specific file from a session
*
* @param sessionId Session identifier
* @param filename Filename to download
* @return File content
*/
@GetMapping("/download/{sessionId}/{filename}")
@Operation(
summary = "Download a specific file",
description =
"Download a file that was uploaded to a session. File is automatically deleted after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "404", description = "File or session not found")
public ResponseEntity<Resource> downloadFile(
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
@Parameter(description = "Filename to download", required = true) @PathVariable
String filename) {
try {
Path filePath = mobileScannerService.getFile(sessionId, filename);
// Read file into memory first, so we can delete it before sending
byte[] fileBytes = Files.readAllBytes(filePath);
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
}
// Delete file immediately after reading into memory (server-side cleanup)
mobileScannerService.deleteFileAfterDownload(sessionId, filename);
// Serve from memory
Resource resource = new org.springframework.core.io.ByteArrayResource(fileBytes);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.warn("File not found: session={}, file={}", sessionId, filename);
return ResponseEntity.notFound().build();
}
}
/**
* Delete a session and all its files
*
* @param sessionId Session to delete
* @return Deletion status
*/
@DeleteMapping("/session/{sessionId}")
@Operation(
summary = "Delete a session",
description = "Manually delete a session and all its uploaded files")
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
public ResponseEntity<Map<String, Object>> deleteSession(
@Parameter(description = "Session ID to delete", required = true) @PathVariable
String sessionId) {
mobileScannerService.deleteSession(sessionId);
return ResponseEntity.ok(
Map.of("success", true, "sessionId", sessionId, "message", "Session deleted"));
}
// Response schemas for OpenAPI documentation
private static class UploadResponse {
public boolean success;
public String sessionId;
public int filesUploaded;
public String message;
}
private static class FileListResponse {
public String sessionId;
public List<FileMetadata> files;
public int count;
}
}

View File

@ -1,53 +1,99 @@
/* Light theme variables */
:root {
--cc-bg: #ffffff;
--cc-primary-color: #1c1c1c;
--cc-secondary-color: #666666;
--cc-btn-primary-bg: #007BFF;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #007BFF;
--cc-btn-primary-hover-bg: #0056b3;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #0056b3;
--cc-btn-secondary-bg: #f1f3f4;
--cc-btn-secondary-color: #1c1c1c;
--cc-btn-secondary-border-color: #f1f3f4;
--cc-btn-secondary-hover-bg: #007BFF;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #007BFF;
--cc-separator-border-color: #e0e0e0;
--cc-toggle-on-bg: #007BFF;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #ffffff;
--cc-toggle-off-knob-bg: #ffffff;
--cc-toggle-enabled-icon-color: #ffffff;
--cc-toggle-disabled-icon-color: #ffffff;
--cc-toggle-readonly-bg: #f1f3f4;
--cc-toggle-readonly-knob-bg: #79747E;
--cc-toggle-readonly-knob-icon-color: #f1f3f4;
--cc-section-category-border: #e0e0e0;
--cc-cookie-category-block-bg: #f1f3f4;
--cc-cookie-category-block-border: #f1f3f4;
--cc-cookie-category-block-hover-bg: #e9eff4;
--cc-cookie-category-block-hover-border: #e9eff4;
--cc-cookie-category-expanded-block-bg: #f1f3f4;
--cc-cookie-category-expanded-block-hover-bg: #e9eff4;
--cc-footer-bg: #ffffff;
--cc-footer-color: #1c1c1c;
--cc-footer-border-color: #ffffff;
}
/* Dark theme variables */
.cc--darkmode{
--cc-bg: var(--md-sys-color-inverse-on-surface);
--cc-primary-color: var(--md-sys-color-on-surface);
--cc-secondary-color: var(--md-sys-color-on-surface);
--cc-bg: #2d2d2d;
--cc-primary-color: #e5e5e5;
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: var(--md-sys-color-secondary);
--cc-btn-primary-color: var(--cc-bg);
--cc-btn-primary-border-color: var(--cc-btn-primary-bg);
--cc-btn-primary-hover-bg: var(--md-sys-color-surface-3);
--cc-btn-primary-hover-color: var(--md-sys-color-on-secondary-container);
--cc-btn-primary-hover-border-color: var(--md-sys-color-surface-3);
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: var(--md-sys-color-surface-3);
--cc-btn-secondary-color: var(--md-sys-color-on-secondary-container);
--cc-btn-secondary-border-color: var(--md-sys-color-surface-3);
--cc-btn-secondary-hover-bg:var(--md-sys-color-secondary);
--cc-btn-secondary-hover-color: var(--cc-bg);
--cc-btn-secondary-hover-border-color: var(--md-sys-color-secondary);
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: var(--md-sys-color-outline);
--cc-separator-border-color: #555555;
--cc-toggle-on-bg: var(--cc-btn-primary-bg);
--cc-toggle-off-bg: var(--md-sys-color-outline);
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-on-bg: #4dabf7;
--cc-toggle-off-bg: #667481;
--cc-toggle-on-knob-bg: #2d2d2d;
--cc-toggle-off-knob-bg: #2d2d2d;
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: #2d2d2d;
--cc-toggle-disabled-icon-color: #2d2d2d;
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
--cc-toggle-readonly-knob-icon-color: var(--cc-toggle-readonly-bg);
--cc-toggle-readonly-bg: #555555;
--cc-toggle-readonly-knob-bg: #8e8e8e;
--cc-toggle-readonly-knob-icon-color: #555555;
--cc-section-category-border: var(--md-sys-color-outline);
--cc-section-category-border: #555555;
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-bg: #3d3d3d;
--cc-cookie-category-block-border: #3d3d3d;
--cc-cookie-category-block-hover-bg: #4d4d4d;
--cc-cookie-category-block-hover-border: #4d4d4d;
--cc-cookie-category-expanded-block-bg: #3d3d3d;
--cc-cookie-category-expanded-block-hover-bg: #4d4d4d;
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
/* --cc-overlay-bg: rgba(0, 0, 0, 0.65);
--cc-webkit-scrollbar-bg: var(--cc-section-category-border);
--cc-webkit-scrollbar-hover-bg: var(--cc-btn-primary-hover-bg);
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
--cc-footer-bg: #2d2d2d;
--cc-footer-color: #e5e5e5;
--cc-footer-border-color: #2d2d2d;
}
.cm__body{
max-width: 90% !important;
@ -78,7 +124,83 @@
}
}
/* Toggle visibility fixes */
#cc-main .section__toggle {
opacity: 0 !important; /* Keep invisible but functional */
}
#cc-main .toggle__icon {
display: flex !important;
align-items: center !important;
justify-content: flex-start !important;
}
#cc-main .toggle__icon-circle {
display: block !important;
position: absolute !important;
transition: transform 0.25s ease !important;
}
#cc-main .toggle__icon-on,
#cc-main .toggle__icon-off {
display: flex !important;
align-items: center !important;
justify-content: center !important;
position: absolute !important;
width: 100% !important;
height: 100% !important;
}
/* Ensure toggles are visible in both themes */
#cc-main .toggle__icon {
background: var(--cc-toggle-off-bg) !important;
border: 1px solid var(--cc-toggle-off-bg) !important;
}
#cc-main .section__toggle:checked ~ .toggle__icon {
background: var(--cc-toggle-on-bg) !important;
border: 1px solid var(--cc-toggle-on-bg) !important;
}
/* Ensure toggle text is visible */
#cc-main .pm__section-title {
color: var(--cc-primary-color) !important;
}
#cc-main .pm__section-desc {
color: var(--cc-secondary-color) !important;
}
/* Make sure the modal has proper contrast */
#cc-main .pm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}

View File

@ -1,20 +1,25 @@
{
"name": "Stirling-PDF",
"short_name": "Stirling-PDF",
"short_name": "Stirling PDF",
"name": "Stirling PDF",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
"src": "modern-logo/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
"src": "modern-logo/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "modern-logo/logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/",
"start_url": ".",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"
"theme_color": "#000000",
"background_color": "#ffffff"
}

File diff suppressed because it is too large Load Diff

View File

@ -50,11 +50,14 @@
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"jscanify": "^1.4.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
"qrcode.react": "^4.1.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",

View File

@ -4805,6 +4805,8 @@ googleDriveShort = "Drive"
myFiles = "My Files"
noRecentFiles = "No recent files found"
googleDriveNotAvailable = "Google Drive integration not available"
mobileUpload = "Mobile Upload"
mobileShort = "Mobile"
downloadSelected = "Download Selected"
saveSelected = "Save Selected"
openFiles = "Open Files"
@ -6254,3 +6256,32 @@ title = "Add Text Results"
[addText.error]
failed = "An error occurred while adding text to the PDF."
[mobileUpload]
title = "Upload from Mobile"
description = "Scan this QR code with your mobile device to upload photos directly to this page."
error = "Connection Error"
sessionId = "Session ID"
connected = "Mobile device connected"
instructions = "Open the camera app on your phone and scan this code. Files will be transferred directly between devices."
[mobileScanner]
title = "Mobile Scanner"
noSession = "Invalid Session"
noSessionMessage = "Please scan a valid QR code to access this page."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
connected = "Connected"
connecting = "Connecting..."
camera = "Camera"
fileUpload = "File Upload"
cameraAccessDenied = "Camera access denied. Please enable camera access."
capture = "Capture Photo"
selectImage = "Select Image"
preview = "Preview"
retake = "Retake"
addToBatch = "Add to Batch"
upload = "Upload"
batchImages = "Batch"
clearBatch = "Clear"
uploadAll = "Upload All"

View File

@ -1,8 +1,13 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@ -13,15 +18,44 @@ import "@app/styles/index.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}

View File

@ -1,12 +1,14 @@
import React from 'react';
import React, { useState } from 'react';
import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import CloudIcon from '@mui/icons-material/Cloud';
import PhonelinkIcon from '@mui/icons-material/Phonelink';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
interface FileSourceButtonsProps {
horizontal?: boolean;
@ -15,12 +17,13 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const UploadIcon = icons.upload;
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const handleGoogleDriveClick = async () => {
try {
@ -33,6 +36,16 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
}
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = (files: File[]) => {
if (files.length > 0) {
onNewFilesSelect(files);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
@ -105,24 +118,59 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<PhonelinkIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleMobileUploadClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)'
}
}
}}
>
{horizontal ? t('fileManager.mobileShort', 'Mobile') : t('fileManager.mobileUpload', 'Mobile Upload')}
</Button>
</>
);
if (horizontal) {
return (
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<>
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
}
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<>
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
};

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Container, Button, Group, useMantineColorScheme, ActionIcon, Tooltip } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
import { useTranslation } from 'react-i18next';
@ -11,6 +11,8 @@ import { useLogoVariant } from '@app/hooks/useLogoVariant';
import { useFileManager } from '@app/hooks/useFileManager';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
import PhonelinkIcon from '@mui/icons-material/Phonelink';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@ -24,6 +26,7 @@ const LandingPage = () => {
const { wordmark } = useLogoAssets();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const [mobileUploadModalOpen, setMobileUploadModalOpen] = React.useState(false);
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
@ -48,6 +51,16 @@ const LandingPage = () => {
event.target.value = '';
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = async (files: File[]) => {
if (files.length > 0) {
await addFiles(files);
}
};
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
@ -202,32 +215,58 @@ const LandingPage = () => {
</span>
)}
</Button>
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
color: 'var(--accent-interactive)',
}}
>
<PhonelinkIcon />
</ActionIcon>
</Tooltip>
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
<>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: 'calc(100% - 38px - 0.6rem)',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
<Tooltip label={t('landing.mobileUpload', 'Upload from Mobile')} position="bottom">
<ActionIcon
size={38}
variant="subtle"
onClick={handleMobileUploadClick}
style={{
color: 'var(--accent-interactive)',
}}
>
<PhonelinkIcon />
</ActionIcon>
</Tooltip>
</>
)}
</div>
@ -251,6 +290,11 @@ const LandingPage = () => {
</span>
</div>
</Dropzone>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</Container>
);
};

View File

@ -0,0 +1,208 @@
import { useEffect, useCallback, useState, useRef } from 'react';
import { Modal, Stack, Text, Badge, Box, Group, Alert } from '@mantine/core';
import { QRCodeSVG } from 'qrcode.react';
import { useTranslation } from 'react-i18next';
import { useFrontendUrl } from '@app/hooks/useFrontendUrl';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
import { withBasePath } from '@app/constants/app';
interface MobileUploadModalProps {
opened: boolean;
onClose: () => void;
onFilesReceived: (files: File[]) => void;
}
// Generate a UUID-like session ID
function generateSessionId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* MobileUploadModal
*
* Displays a QR code that mobile devices can scan to upload files via backend server.
* Files are temporarily stored on server and retrieved by desktop.
*/
export default function MobileUploadModal({ opened, onClose, onFilesReceived }: MobileUploadModalProps) {
const { t } = useTranslation();
const frontendUrl = useFrontendUrl();
const [sessionId] = useState(() => generateSessionId());
const [filesReceived, setFilesReceived] = useState(0);
const [error, setError] = useState<string | null>(null);
const pollIntervalRef = useRef<number | null>(null);
const processedFiles = useRef<Set<string>>(new Set());
// Use configured frontendUrl if set, otherwise use current origin
// Combine with base path and mobile-scanner route
const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`;
const pollForFiles = useCallback(async () => {
if (!opened) return;
try {
const response = await fetch(`/api/v1/mobile-scanner/files/${sessionId}`);
if (!response.ok) {
throw new Error('Failed to check for files');
}
const data = await response.json();
const files = data.files || [];
// Download only files we haven't processed yet
const newFiles = files.filter((f: any) => !processedFiles.current.has(f.filename));
if (newFiles.length > 0) {
for (const fileMetadata of newFiles) {
try {
const downloadResponse = await fetch(
`/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`
);
if (downloadResponse.ok) {
const blob = await downloadResponse.blob();
const file = new File([blob], fileMetadata.filename, {
type: fileMetadata.contentType || 'image/jpeg'
});
processedFiles.current.add(fileMetadata.filename);
setFilesReceived((prev) => prev + 1);
onFilesReceived([file]);
}
} catch (err) {
console.error('Failed to download file:', fileMetadata.filename, err);
}
}
// Delete the entire session immediately after downloading all files
// This ensures files are only on server for ~1 second
try {
await fetch(`/api/v1/mobile-scanner/session/${sessionId}`, { method: 'DELETE' });
console.log('Session cleaned up after file download');
} catch (cleanupErr) {
console.warn('Failed to cleanup session after download:', cleanupErr);
}
}
} catch (err) {
console.error('Error polling for files:', err);
setError(t('mobileUpload.pollingError', 'Error checking for files'));
}
}, [opened, sessionId, onFilesReceived, t]);
// Start polling when modal opens
useEffect(() => {
if (opened) {
setFilesReceived(0);
setError(null);
processedFiles.current.clear();
// Poll every 2 seconds
pollIntervalRef.current = window.setInterval(pollForFiles, 2000);
// Initial poll
pollForFiles();
} else {
// Stop polling when modal closes
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, [opened, pollForFiles]);
return (
<Modal
opened={opened}
onClose={onClose}
title={t('mobileUpload.title', 'Upload from Mobile')}
centered
size="md"
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Stack gap="md">
<Alert
icon={<InfoRoundedIcon style={{ fontSize: '1rem' }} />}
color="blue"
variant="light"
>
<Text size="sm">
{t(
'mobileUpload.description',
'Scan this QR code with your mobile device to upload photos directly to this page.'
)}
</Text>
</Alert>
{error && (
<Alert
icon={<ErrorRoundedIcon style={{ fontSize: '1rem' }} />}
title={t('mobileUpload.error', 'Connection Error')}
color="red"
>
<Text size="sm">{error}</Text>
</Alert>
)}
<Box style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Box
style={{
padding: '1.5rem',
background: 'white',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<QRCodeSVG value={mobileUrl} size={256} level="H" includeMargin />
</Box>
<Group gap="xs">
<Text size="sm" c="dimmed">
{t('mobileUpload.sessionId', 'Session ID')}:
</Text>
<Badge variant="light" color="blue" size="lg">
{sessionId}
</Badge>
</Group>
{filesReceived > 0 && (
<Badge variant="filled" color="green" size="lg" leftSection={<CheckRoundedIcon style={{ fontSize: '1rem' }} />}>
{t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })}
</Badge>
)}
<Text size="xs" c="dimmed" ta="center" style={{ maxWidth: '300px' }}>
{t(
'mobileUpload.instructions',
'Open the camera app on your phone and scan this code. Files will be uploaded through the server.'
)}
</Text>
<Text
size="xs"
c="dimmed"
style={{
wordBreak: 'break-all',
textAlign: 'center',
fontFamily: 'monospace',
}}
>
{mobileUrl}
</Text>
</Box>
</Stack>
</Modal>
);
}

View File

@ -0,0 +1,36 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
interface AppConfig {
frontendUrl?: string;
[key: string]: unknown;
}
/**
* Hook to get the configured frontend URL from backend app-config.
* Falls back to window.location.origin if not configured.
*/
export const useFrontendUrl = (): string => {
const [frontendUrl, setFrontendUrl] = useState<string>(window.location.origin);
useEffect(() => {
const fetchFrontendUrl = async () => {
try {
const response = await apiClient.get<AppConfig>('/api/v1/app-config');
const configuredUrl = response.data.frontendUrl;
// Use configured URL if not empty, otherwise keep window.location.origin
if (configuredUrl && configuredUrl.trim() !== '') {
setFrontendUrl(configuredUrl);
}
} catch (error) {
console.warn('Failed to fetch app config, using window.location.origin:', error);
// Keep the default window.location.origin on error
}
};
fetchFrontendUrl();
}, []);
return frontendUrl;
};

View File

@ -0,0 +1,910 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Box, Button, Stack, Text, Group, Alert, Tabs, Progress, Switch, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useLogoPath } from '@app/hooks/useLogoPath';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded';
import InfoRoundedIcon from '@mui/icons-material/InfoRounded';
import PhotoCameraRoundedIcon from '@mui/icons-material/PhotoCameraRounded';
import UploadRoundedIcon from '@mui/icons-material/UploadRounded';
import AddPhotoAlternateRoundedIcon from '@mui/icons-material/AddPhotoAlternateRounded';
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
// @ts-ignore - jscanify doesn't have TypeScript definitions
import jscanify from 'jscanify/src/jscanify.js';
/**
* MobileScannerPage
*
* Mobile-friendly page for capturing photos and uploading them to the backend server.
* Accessed by scanning QR code from desktop.
*/
export default function MobileScannerPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const sessionId = searchParams.get('session');
const { colorScheme } = useMantineColorScheme();
const brandIconSrc = useLogoPath();
const { wordmark } = useLogoAssets();
const brandTextSrc = colorScheme === 'dark' ? wordmark.white : wordmark.black;
const [activeTab, setActiveTab] = useState<string | null>('camera');
const [capturedImages, setCapturedImages] = useState<string[]>([]);
const [currentPreview, setCurrentPreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [cameraError, setCameraError] = useState<string | null>(null);
const [autoEnhance, setAutoEnhance] = useState(true);
const [showLiveDetection, setShowLiveDetection] = useState(true); // On by default with adaptive performance
const [isProcessing, setIsProcessing] = useState(false);
const [openCvReady, setOpenCvReady] = useState(false);
const [torchEnabled, setTorchEnabled] = useState(false);
const [torchSupported, setTorchSupported] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const highlightCanvasRef = useRef<HTMLCanvasElement>(null);
const detectionCanvasRef = useRef<HTMLCanvasElement>(null); // Low-res canvas for detection only
const streamRef = useRef<MediaStream | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const scannerRef = useRef<any>(null);
const highlightIntervalRef = useRef<number | null>(null);
// Detection resolution - extremely low for mobile performance
const DETECTION_WIDTH = 160; // Ultra-low for real-time mobile detection
// Initialize jscanify scanner and wait for OpenCV
useEffect(() => {
let script: HTMLScriptElement | null = null;
const loadOpenCV = () => {
// Check if OpenCV is already loaded
if ((window as any).cv && (window as any).cv.Mat) {
initScanner();
return;
}
// Check if script already exists
const existingScript = document.querySelector('script[src*="opencv.js"]');
if (existingScript) {
// Script exists, wait for it to load
(window as any).onOpenCvReady = initScanner;
return;
}
// Load OpenCV.js from CDN
script = document.createElement('script');
script.src = 'https://docs.opencv.org/4.7.0/opencv.js';
script.async = true;
script.onload = () => {
console.log('OpenCV.js loaded');
// OpenCV needs a moment to initialize after script loads
setTimeout(initScanner, 100);
};
script.onerror = () => {
console.error('Failed to load OpenCV.js');
};
document.head.appendChild(script);
};
const initScanner = () => {
// Verify OpenCV is actually ready
if (!(window as any).cv || !(window as any).cv.Mat) {
console.warn('OpenCV not ready yet, retrying...');
setTimeout(initScanner, 100);
return;
}
try {
scannerRef.current = new jscanify();
setOpenCvReady(true);
console.log('jscanify initialized with OpenCV');
} catch (err) {
console.error('Failed to initialize jscanify:', err);
}
};
// Start loading process
if (document.readyState === 'complete') {
loadOpenCV();
} else {
window.addEventListener('load', loadOpenCV);
}
return () => {
window.removeEventListener('load', loadOpenCV);
// Don't remove script on unmount as other components might use it
};
}, []);
// Initialize camera
useEffect(() => {
if (activeTab === 'camera' && !cameraError && !currentPreview) {
// Check if mediaDevices API is available (requires HTTPS or localhost)
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
console.error('MediaDevices API not available - requires HTTPS or localhost');
setCameraError(
t(
'mobileScanner.httpsRequired',
'Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost.'
)
);
setActiveTab('file');
return;
}
navigator.mediaDevices
.getUserMedia({
video: {
facingMode: 'environment',
// Request 1080p - good quality without going overboard
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
},
audio: false,
})
.then(async (stream) => {
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
// Log actual resolution we got
const videoTrack = stream.getVideoTracks()[0];
const settings = videoTrack.getSettings();
console.log('Camera resolution:', settings.width, 'x', settings.height);
// Configure camera capabilities for document scanning
try {
const capabilities = videoTrack.getCapabilities();
const constraints: any = { advanced: [] };
// 1. Enable continuous autofocus
if (capabilities.focusMode && capabilities.focusMode.includes('continuous')) {
constraints.advanced.push({ focusMode: 'continuous' });
console.log('✓ Continuous autofocus enabled');
}
// 2. Enable continuous auto-exposure for varying lighting
if (capabilities.exposureMode && capabilities.exposureMode.includes('continuous')) {
constraints.advanced.push({ exposureMode: 'continuous' });
console.log('✓ Auto-exposure enabled');
}
// 3. Check if torch/flashlight is supported
if (capabilities.torch) {
setTorchSupported(true);
console.log('✓ Torch/flashlight available');
}
// Apply all constraints
if (constraints.advanced.length > 0) {
await videoTrack.applyConstraints(constraints);
}
} catch (err) {
console.log('Could not configure camera features:', err);
}
}
})
.catch((err) => {
console.error('Camera error:', err);
setCameraError(t('mobileScanner.cameraAccessDenied', 'Camera access denied. Please enable camera access.'));
// Auto-switch to file upload if camera fails
setActiveTab('file');
});
}
return () => {
// Clean up stream when switching away from camera or showing preview
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
// Stop highlighting when camera is stopped
if (highlightIntervalRef.current) {
clearInterval(highlightIntervalRef.current);
highlightIntervalRef.current = null;
}
};
}, [activeTab, cameraError, currentPreview, t]);
// Real-time document highlighting on camera feed
useEffect(() => {
console.log(`[Mobile Scanner] Effect triggered: activeTab=${activeTab}, showLiveDetection=${showLiveDetection}, openCvReady=${openCvReady}, currentPreview=${currentPreview}`);
if (activeTab === 'camera' && showLiveDetection && openCvReady && scannerRef.current && !currentPreview) {
const startHighlighting = () => {
if (!videoRef.current || !highlightCanvasRef.current) return;
if (!videoRef.current.videoWidth || !videoRef.current.videoHeight) return;
const video = videoRef.current;
const highlightCanvas = highlightCanvasRef.current;
// Create low-res detection canvas with optimized context for frequent pixel reading
const detectionCanvas = document.createElement('canvas');
const detectionCtx = detectionCanvas.getContext('2d', { willReadFrequently: true });
if (!detectionCtx) return;
// Calculate scaled dimensions for detection (160px wide max)
const scale = DETECTION_WIDTH / video.videoWidth;
detectionCanvas.width = DETECTION_WIDTH;
detectionCanvas.height = Math.round(video.videoHeight * scale);
// CRITICAL FIX: Make highlight canvas ALSO low-res (CSS will scale it visually)
// Drawing to a 4K canvas is what was causing the lag!
highlightCanvas.width = DETECTION_WIDTH;
highlightCanvas.height = Math.round(video.videoHeight * scale);
console.log(`[Mobile Scanner] Video: ${video.videoWidth}x${video.videoHeight}`);
console.log(`[Mobile Scanner] Detection: ${detectionCanvas.width}x${detectionCanvas.height} (${Math.round(scale * 100)}%)`);
console.log(`[Mobile Scanner] Highlight canvas: ${highlightCanvas.width}x${highlightCanvas.height}`);
console.log(`[Mobile Scanner] Starting interval at 1 FPS`);
// Set highlight canvas to match video for vector drawing
highlightCanvas.width = video.videoWidth;
highlightCanvas.height = video.videoHeight;
const highlightCtx = highlightCanvas.getContext('2d', { willReadFrequently: true });
if (!highlightCtx) return;
// Use requestAnimationFrame with adaptive throttle based on device performance
let frameCount = 0;
const frameTimes: number[] = [];
let lastDetectionTime = 0;
let detectionInterval = 333; // Start at 3 FPS (333ms)
const detectionTimings: number[] = []; // Track last 10 detection times
const MAX_TIMINGS = 10;
const runDetection = () => {
const now = performance.now();
// Only run detection every second
if (now - lastDetectionTime >= detectionInterval) {
lastDetectionTime = now;
const startTime = performance.now();
try {
// Step 1: Copy video to low-res detection canvas
const copyStart = performance.now();
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
const copyTime = performance.now() - copyStart;
// Step 2: Run detection on low-res to get corner points
const detectionStart = performance.now();
const mat = (window as any).cv.imread(detectionCanvas);
const contour = scannerRef.current.findPaperContour(mat);
let corners = null;
if (contour) {
// Validate contour area (reject if too small or too large)
const contourArea = (window as any).cv.contourArea(contour);
const frameArea = detectionCanvas.width * detectionCanvas.height;
const areaPercent = (contourArea / frameArea) * 100;
// Only accept if contour is 15-85% of frame (filters out noise and frame edges)
if (areaPercent >= 15 && areaPercent <= 85) {
corners = scannerRef.current.getCornerPoints(contour);
}
}
mat.delete();
const detectionTime = performance.now() - detectionStart;
// Step 3: Draw ONLY the corner lines on full-res canvas (super fast!)
const drawStart = performance.now();
highlightCtx.clearRect(0, 0, highlightCanvas.width, highlightCanvas.height);
// Validate we have all 4 corners (no triangles!)
if (
corners &&
corners.topLeftCorner &&
corners.topRightCorner &&
corners.bottomLeftCorner &&
corners.bottomRightCorner
) {
// Scale corner points from low-res to full-res
const scaleFactor = video.videoWidth / detectionCanvas.width;
const tl = { x: corners.topLeftCorner.x * scaleFactor, y: corners.topLeftCorner.y * scaleFactor };
const tr = { x: corners.topRightCorner.x * scaleFactor, y: corners.topRightCorner.y * scaleFactor };
const br = { x: corners.bottomRightCorner.x * scaleFactor, y: corners.bottomRightCorner.y * scaleFactor };
const bl = { x: corners.bottomLeftCorner.x * scaleFactor, y: corners.bottomLeftCorner.y * scaleFactor };
// Validation 1: Minimum distance between corners
const minDistance = 50;
const distances = [
Math.hypot(tr.x - tl.x, tr.y - tl.y),
Math.hypot(br.x - tr.x, br.y - tr.y),
Math.hypot(bl.x - br.x, bl.y - br.y),
Math.hypot(tl.x - bl.x, tl.y - bl.y),
];
const allCornersSpaced = distances.every((d) => d > minDistance);
// Validation 2: Aspect ratio (documents are ~1.0 to 1.5 ratio)
const width1 = Math.hypot(tr.x - tl.x, tr.y - tl.y);
const width2 = Math.hypot(br.x - bl.x, br.y - bl.y);
const height1 = Math.hypot(bl.x - tl.x, bl.y - tl.y);
const height2 = Math.hypot(br.x - tr.x, br.y - tr.y);
const avgWidth = (width1 + width2) / 2;
const avgHeight = (height1 + height2) / 2;
const aspectRatio = Math.max(avgWidth, avgHeight) / Math.min(avgWidth, avgHeight);
// Accept aspect ratios from 1:1 (square) to 1:2 (elongated document)
const goodAspectRatio = aspectRatio >= 1.0 && aspectRatio <= 2.0;
if (allCornersSpaced && goodAspectRatio) {
// Draw lines connecting corners (vector graphics - super lightweight!)
highlightCtx.strokeStyle = '#00FF00';
highlightCtx.lineWidth = 4;
highlightCtx.beginPath();
highlightCtx.moveTo(tl.x, tl.y);
highlightCtx.lineTo(tr.x, tr.y);
highlightCtx.lineTo(br.x, br.y);
highlightCtx.lineTo(bl.x, bl.y);
highlightCtx.lineTo(tl.x, tl.y);
highlightCtx.stroke();
}
}
const drawTime = performance.now() - drawStart;
const totalTime = performance.now() - startTime;
frameCount++;
frameTimes.push(totalTime);
// Track detection timings for adaptive performance
detectionTimings.push(totalTime);
if (detectionTimings.length > MAX_TIMINGS) {
detectionTimings.shift(); // Keep only last 10
}
// Adaptive performance adjustment (after warmup period)
if (frameCount > 5 && detectionTimings.length >= 5) {
const avgTime = detectionTimings.reduce((a, b) => a + b, 0) / detectionTimings.length;
// Adjust detection interval based on average performance
if (avgTime < 20) {
// Very fast device: 5 FPS (200ms)
detectionInterval = 200;
} else if (avgTime < 40) {
// Fast device: 3 FPS (333ms)
detectionInterval = 333;
} else if (avgTime < 80) {
// Medium device: 2 FPS (500ms)
detectionInterval = 500;
} else {
// Slower device: 1 FPS (1000ms)
detectionInterval = 1000;
}
}
if (frameCount <= 10) {
console.log(`[Mobile Scanner] Frame ${frameCount}: ${Math.round(totalTime)}ms total (copy: ${Math.round(copyTime)}ms, detect: ${Math.round(detectionTime)}ms, draw: ${Math.round(drawTime)}ms) - interval: ${detectionInterval}ms`);
}
if (frameCount === 10) {
const avg = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length;
console.log(`[Mobile Scanner] Average of first 10 frames: ${Math.round(avg)}ms - Adaptive rate: ${Math.round(1000/detectionInterval)} FPS`);
}
} catch (err) {
console.error('[Mobile Scanner] Detection error:', err);
}
}
// Continue animation loop
highlightIntervalRef.current = requestAnimationFrame(runDetection);
};
// Start the animation loop
highlightIntervalRef.current = requestAnimationFrame(runDetection);
};
// Wait for video to be ready
if (videoRef.current && videoRef.current.readyState >= 2) {
startHighlighting();
} else if (videoRef.current) {
videoRef.current.addEventListener('loadedmetadata', startHighlighting);
}
return () => {
if (highlightIntervalRef.current) {
console.log('[Mobile Scanner] Stopping detection');
cancelAnimationFrame(highlightIntervalRef.current);
highlightIntervalRef.current = null;
}
};
}
}, [activeTab, showLiveDetection, openCvReady, currentPreview]);
const captureImage = useCallback(async () => {
if (!videoRef.current || !canvasRef.current) return;
setIsProcessing(true);
try {
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
if (!context) return;
// Capture raw image from video at full resolution
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
let finalDataUrl: string;
// Apply jscanify processing if enabled and available
if (autoEnhance && scannerRef.current && openCvReady) {
try {
// Create low-res canvas for detection (faster processing)
const detectionCanvas = document.createElement('canvas');
const detectionCtx = detectionCanvas.getContext('2d', { willReadFrequently: true });
if (!detectionCtx) throw new Error('Cannot create detection context');
const scale = DETECTION_WIDTH / video.videoWidth;
detectionCanvas.width = DETECTION_WIDTH;
detectionCanvas.height = Math.round(video.videoHeight * scale);
// Draw downscaled image for detection
detectionCtx.drawImage(video, 0, 0, detectionCanvas.width, detectionCanvas.height);
// Run detection on low-res image
const mat = (window as any).cv.imread(detectionCanvas);
const contour = scannerRef.current.findPaperContour(mat);
if (contour) {
const cornerPoints = scannerRef.current.getCornerPoints(contour);
// Scale corner points back to full resolution
if (cornerPoints) {
const scaleFactor = 1 / scale;
const scaledCorners = {
topLeftCorner: { x: cornerPoints.topLeftCorner.x * scaleFactor, y: cornerPoints.topLeftCorner.y * scaleFactor },
topRightCorner: { x: cornerPoints.topRightCorner.x * scaleFactor, y: cornerPoints.topRightCorner.y * scaleFactor },
bottomLeftCorner: { x: cornerPoints.bottomLeftCorner.x * scaleFactor, y: cornerPoints.bottomLeftCorner.y * scaleFactor },
bottomRightCorner: { x: cornerPoints.bottomRightCorner.x * scaleFactor, y: cornerPoints.bottomRightCorner.y * scaleFactor },
};
// Use scaled corners for validation and extraction
const { topLeftCorner, topRightCorner, bottomLeftCorner, bottomRightCorner } = scaledCorners;
// Validate corner points are reasonable (minimum distance in full-res)
const minDistance = 100; // Minimum pixels between corners at full resolution
const distances = [
Math.hypot(topRightCorner.x - topLeftCorner.x, topRightCorner.y - topLeftCorner.y),
Math.hypot(bottomRightCorner.x - topRightCorner.x, bottomRightCorner.y - topRightCorner.y),
Math.hypot(bottomLeftCorner.x - bottomRightCorner.x, bottomLeftCorner.y - bottomRightCorner.y),
Math.hypot(topLeftCorner.x - bottomLeftCorner.x, topLeftCorner.y - bottomLeftCorner.y),
];
const isValidDetection = distances.every((d) => d > minDistance);
if (!isValidDetection) {
console.warn('Detected corners are too close together, using original image');
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
return;
}
console.log('Valid document detected at full resolution:', {
corners: scaledCorners,
distances: distances.map((d) => Math.round(d)),
});
// Calculate width and height of the document
const topWidth = Math.hypot(topRightCorner.x - topLeftCorner.x, topRightCorner.y - topLeftCorner.y);
const bottomWidth = Math.hypot(bottomRightCorner.x - bottomLeftCorner.x, bottomRightCorner.y - bottomLeftCorner.y);
const leftHeight = Math.hypot(bottomLeftCorner.x - topLeftCorner.x, bottomLeftCorner.y - topLeftCorner.y);
const rightHeight = Math.hypot(bottomRightCorner.x - topRightCorner.x, bottomRightCorner.y - topRightCorner.y);
// Use average dimensions to maintain proper aspect ratio
const docWidth = Math.round((topWidth + bottomWidth) / 2);
const docHeight = Math.round((leftHeight + rightHeight) / 2);
// Extract paper from full-resolution canvas with scaled corner points
const resultCanvas = scannerRef.current.extractPaper(canvas, docWidth, docHeight, scaledCorners);
// Use high quality JPEG compression to preserve image quality
finalDataUrl = resultCanvas.toDataURL('image/jpeg', 0.95);
} else {
console.log('No corners detected, using original');
mat.delete();
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
} else {
console.log('No contour detected, using original');
mat.delete();
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
} catch (err) {
console.warn('jscanify processing failed, using original image:', err);
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
} else {
// Auto-enhance disabled or jscanify not available - use original at high quality
finalDataUrl = canvas.toDataURL('image/jpeg', 0.95);
}
setCurrentPreview(finalDataUrl);
} finally {
setIsProcessing(false);
}
}, [autoEnhance, openCvReady]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setCurrentPreview(event.target.result as string);
}
};
reader.readAsDataURL(file);
}, []);
const addToBatch = useCallback(() => {
if (currentPreview) {
setCapturedImages((prev) => [...prev, currentPreview]);
setCurrentPreview(null);
}
}, [currentPreview]);
const uploadImages = useCallback(async () => {
const imagesToUpload = currentPreview ? [currentPreview, ...capturedImages] : capturedImages;
if (imagesToUpload.length === 0) return;
if (!sessionId) return;
setIsUploading(true);
setUploadError(null);
setUploadProgress(0);
try {
// Convert data URLs to File objects
const files: File[] = [];
for (let i = 0; i < imagesToUpload.length; i++) {
const dataUrl = imagesToUpload[i];
const response = await fetch(dataUrl);
const blob = await response.blob();
const file = new File([blob], `scan-${Date.now()}-${i}.jpg`, { type: 'image/jpeg' });
files.push(file);
setUploadProgress(((i + 1) / (imagesToUpload.length + 1)) * 50); // 0-50% for conversion
}
// Upload to backend
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const uploadResponse = await fetch(`/api/v1/mobile-scanner/upload/${sessionId}`, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
setUploadProgress(100);
setUploadSuccess(true);
// Close the mobile tab after successful upload
setTimeout(() => {
window.close();
// Fallback if window.close() doesn't work (some browsers block it)
if (!window.closed) {
navigate('/');
}
}, 1500);
} catch (err) {
console.error('Upload failed:', err);
setUploadError(t('mobileScanner.uploadFailed', 'Upload failed. Please try again.'));
} finally {
setIsUploading(false);
}
}, [currentPreview, capturedImages, sessionId, navigate, t]);
const retake = useCallback(() => {
setCurrentPreview(null);
}, []);
const clearBatch = useCallback(() => {
setCapturedImages([]);
}, []);
const toggleTorch = useCallback(async () => {
if (!streamRef.current) return;
try {
const videoTrack = streamRef.current.getVideoTracks()[0];
await videoTrack.applyConstraints({
advanced: [{ torch: !torchEnabled }],
});
setTorchEnabled(!torchEnabled);
console.log('Torch:', !torchEnabled ? 'ON' : 'OFF');
} catch (err) {
console.error('Failed to toggle torch:', err);
}
}, [torchEnabled]);
if (!sessionId) {
return (
<Box p="xl">
<Alert color="red" title={t('mobileScanner.noSession', 'Invalid Session')}>
{t('mobileScanner.noSessionMessage', 'Please scan a valid QR code to access this page.')}
</Alert>
</Box>
);
}
if (uploadSuccess) {
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
padding: '2rem',
}}
>
<CheckCircleRoundedIcon style={{ fontSize: '4rem', color: 'var(--mantine-color-green-6)' }} />
<Text size="xl" fw="bold" mt="md">
{t('mobileScanner.uploadSuccess', 'Upload Successful!')}
</Text>
<Text size="sm" c="dimmed">
{t('mobileScanner.uploadSuccessMessage', 'Your images have been transferred.')}
</Text>
</Box>
);
}
return (
<Box
style={{
minHeight: '100vh',
background: 'var(--bg-background)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<Box
p="md"
style={{
background: 'var(--bg-toolbar)',
borderBottom: '1px solid var(--border-subtle)',
}}
>
<Group gap="sm" align="center">
<img
src={brandIconSrc}
alt={t('home.mobile.brandAlt', 'Stirling PDF logo')}
style={{ height: '32px', width: '32px' }}
/>
<img
src={brandTextSrc}
alt="Stirling PDF"
style={{ height: '24px' }}
/>
</Group>
</Box>
{uploadError && (
<Box p="md">
<Alert color="red" icon={<ErrorRoundedIcon />} onClose={() => setUploadError(null)} withCloseButton>
{uploadError}
</Alert>
</Box>
)}
{isUploading && (
<Box p="sm">
<Text size="sm" mb="xs">
{t('mobileScanner.uploading', 'Uploading...')}
</Text>
<Progress value={uploadProgress} animated />
</Box>
)}
{cameraError && (
<Box p="md">
<Alert color="orange" icon={<InfoRoundedIcon />}>
{cameraError}
</Alert>
</Box>
)}
{!currentPreview && (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow>
<Tabs.Tab value="camera" leftSection={<PhotoCameraRoundedIcon />}>
{t('mobileScanner.camera', 'Camera')}
</Tabs.Tab>
<Tabs.Tab value="file" leftSection={<UploadRoundedIcon />}>
{t('mobileScanner.fileUpload', 'File Upload')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="camera" pt="md">
<Box style={{ position: 'relative', width: '100%', maxWidth: '100vw', background: '#000', overflow: 'hidden' }}>
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
maxHeight: '60vh',
display: 'block',
objectFit: 'contain',
}}
/>
<canvas ref={canvasRef} style={{ display: 'none' }} />
{/* Highlight overlay canvas - shows real-time document edge detection */}
<canvas
ref={highlightCanvasRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
opacity: showLiveDetection ? 1 : 0,
transition: 'opacity 0.2s',
objectFit: 'contain', // Maintain aspect ratio
imageRendering: 'auto', // Smooth scaling
}}
/>
</Box>
<Stack gap="sm" p="sm">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('mobileScanner.liveDetection', 'Live Detection')}
</Text>
<Switch
checked={showLiveDetection}
onChange={(e) => setShowLiveDetection(e.currentTarget.checked)}
disabled={!openCvReady}
/>
</Group>
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('mobileScanner.autoEnhance', 'Auto-enhance')}
</Text>
<Switch
checked={autoEnhance}
onChange={(e) => setAutoEnhance(e.currentTarget.checked)}
disabled={!openCvReady}
/>
</Group>
{torchSupported && (
<Group justify="space-between">
<Text size="sm" fw={600}>
{t('mobileScanner.flashlight', 'Flashlight')}
</Text>
<Switch checked={torchEnabled} onChange={toggleTorch} />
</Group>
)}
</Stack>
<Button
fullWidth
size="lg"
onClick={captureImage}
loading={isProcessing}
>
{isProcessing
? t('mobileScanner.processing', 'Processing...')
: t('mobileScanner.capture', 'Capture Photo')}
</Button>
{autoEnhance && openCvReady && (
<Text size="xs" c="dimmed" ta="center">
{t(
'mobileScanner.autoEnhanceInfo',
'Document edges will be automatically detected and perspective corrected'
)}
</Text>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="file">
<Stack gap="sm" p="sm" align="center">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Button
size="lg"
onClick={() => fileInputRef.current?.click()}
leftSection={<AddPhotoAlternateRoundedIcon />}
>
{t('mobileScanner.selectImage', 'Select Image')}
</Button>
</Stack>
</Tabs.Panel>
</Tabs>
)}
{currentPreview && (
<Stack gap="sm" p="sm">
<Text size="lg" fw={600}>
{t('mobileScanner.preview', 'Preview')}
</Text>
<Box
style={{
width: '100%',
background: '#000',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<img src={currentPreview} alt="Preview" style={{ width: '100%', display: 'block' }} />
</Box>
<Group grow>
<Button variant="outline" onClick={retake}>
{t('mobileScanner.retake', 'Retake')}
</Button>
<Button variant="light" onClick={addToBatch} color="green">
{t('mobileScanner.addToBatch', 'Add to Batch')}
</Button>
</Group>
<Button fullWidth onClick={uploadImages} loading={isUploading}>
{t('mobileScanner.upload', 'Upload')}
</Button>
</Stack>
)}
{capturedImages.length > 0 && (
<Box p="sm" style={{ borderTop: '1px solid var(--border-subtle)' }}>
<Group justify="space-between" mb="sm">
<Text size="sm" fw={600}>
{t('mobileScanner.batchImages', 'Batch')} ({capturedImages.length})
</Text>
<Group gap="xs">
<Button size="xs" variant="outline" onClick={clearBatch} color="red">
{t('mobileScanner.clearBatch', 'Clear')}
</Button>
<Button size="xs" onClick={uploadImages} loading={isUploading}>
{t('mobileScanner.uploadAll', 'Upload All')}
</Button>
</Group>
</Group>
<Box style={{ display: 'flex', gap: 'var(--space-sm)', overflowX: 'auto', paddingBottom: 'var(--space-sm)' }}>
{capturedImages.map((img, idx) => (
<Box
key={idx}
style={{
minWidth: '80px',
height: '80px',
borderRadius: 'var(--radius-sm)',
overflow: 'hidden',
border: '2px solid var(--border-subtle)',
}}
>
<img src={img} alt={`Capture ${idx + 1}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
))}
</Box>
</Box>
)}
</Box>
);
}

View File

@ -3,11 +3,14 @@ import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@ -19,24 +22,53 @@ import "@app/styles/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
{children}
</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}