mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
photo scan
This commit is contained in:
parent
63550e0c7d
commit
1beed8dc57
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -32,7 +32,8 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"principal",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"async");
|
||||
"async",
|
||||
"session");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
838
frontend/package-lock.json
generated
838
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
208
frontend/src/core/components/shared/MobileUploadModal.tsx
Normal file
208
frontend/src/core/components/shared/MobileUploadModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/core/hooks/useFrontendUrl.ts
Normal file
36
frontend/src/core/hooks/useFrontendUrl.ts
Normal 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;
|
||||
};
|
||||
910
frontend/src/core/pages/MobileScannerPage.tsx
Normal file
910
frontend/src/core/pages/MobileScannerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user