mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-06-12 18:45:03 +00:00
camers scan init
This commit is contained in:
parent
488fe253aa
commit
a187f0599b
@ -13,7 +13,8 @@
|
||||
"Bash(fi)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(./testing/cucumber/features/environment.py -v)",
|
||||
"Bash(./testing/cucumber/features/autojob.feature -v)"
|
||||
"Bash(./testing/cucumber/features/autojob.feature -v)",
|
||||
"Bash(git rev-parse:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
56
scan-upload-improvements.md
Normal file
56
scan-upload-improvements.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Scan Upload Feature Improvements
|
||||
|
||||
## Code Improvements
|
||||
|
||||
1. **Modular JavaScript Architecture**
|
||||
- Split the monolithic `scan-upload-direct.js` into logical modules:
|
||||
- `logger.js`: Handles logging and status updates
|
||||
- `peer-connection.js`: Manages WebRTC peer connections
|
||||
- `camera.js`: Controls mobile camera functionality
|
||||
- `scan-upload.js`: Main module that coordinates everything
|
||||
|
||||
2. **Separated CSS from HTML**
|
||||
- Created dedicated CSS files:
|
||||
- `scan-upload.css`: Styles for the desktop scan-upload page
|
||||
- `mobile-scanner.css`: Styles for the mobile camera interface
|
||||
|
||||
3. **Improved Error Handling**
|
||||
- Added better error handling throughout the codebase
|
||||
- Improved user feedback for connection and camera issues
|
||||
- Enhanced debug logging capabilities
|
||||
|
||||
4. **Backward Compatibility**
|
||||
- Created a compatibility layer that maintains the old API
|
||||
- Allows gradual migration to the new code structure
|
||||
- Ensures existing integrations won't break
|
||||
|
||||
## UI Improvements
|
||||
|
||||
1. **Enhanced Responsive Design**
|
||||
- Improved mobile layout with proper media queries
|
||||
- Better handling of different screen sizes
|
||||
|
||||
2. **Better Visual Feedback**
|
||||
- Clearer status messages for users
|
||||
- Improved styling of the scan result display
|
||||
- Enhanced debug information presentation
|
||||
|
||||
3. **Localization Support**
|
||||
- Added proper Thymeleaf text references for all UI elements
|
||||
- Uses the existing messages system for translations
|
||||
|
||||
## Security Improvements
|
||||
|
||||
1. **Enhanced WebRTC Implementation**
|
||||
- Better handling of connection errors
|
||||
- Improved security for peer connections
|
||||
- Structured error handling for failed connections
|
||||
|
||||
## Next Steps
|
||||
|
||||
Potential future improvements:
|
||||
|
||||
1. Add more robust testing for the WebRTC functionality
|
||||
2. Consider implementing a fallback method if WebRTC is not available
|
||||
3. Add support for scanning multiple documents in one session
|
||||
4. Implement better image quality control options
|
@ -24,7 +24,9 @@ public class CleanUrlInterceptor implements HandlerInterceptor {
|
||||
"file",
|
||||
"messageType",
|
||||
"infoMessage",
|
||||
"async");
|
||||
"async",
|
||||
"session",
|
||||
"t");
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
|
@ -173,6 +173,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "show-javascript");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "scan-upload");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
|
@ -0,0 +1,24 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Controller for scan upload functionality - WebRTC version.
|
||||
*
|
||||
* This controller is completely empty as all functionality has been moved to WebRTC.
|
||||
* The image transfer happens directly between browsers without any server involvement.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class ScanUploadController {
|
||||
// All functionality has been moved to client-side WebRTC
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Web controller for the scan-upload functionality.
|
||||
*/
|
||||
@Controller
|
||||
@Tag(name = "Scan Upload", description = "Scan Upload Web Interface")
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ScanUploadWebController {
|
||||
|
||||
/**
|
||||
* Serves the scan-upload page for desktop/PC view.
|
||||
*
|
||||
* @param model the Spring MVC model
|
||||
* @return the template name to render
|
||||
*/
|
||||
@GetMapping("/scan-upload")
|
||||
@Hidden
|
||||
public String scanUploadForm(Model model) {
|
||||
model.addAttribute("currentPage", "scan-upload");
|
||||
return "misc/scan-upload";
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the mobile page for camera capture and upload.
|
||||
*
|
||||
* @param model the Spring MVC model
|
||||
* @param session the session ID parameter
|
||||
* @return the template name to render
|
||||
*/
|
||||
@GetMapping("/mobile")
|
||||
@Hidden
|
||||
public String mobileView(Model model, @RequestParam(name = "session", required = false) String session) {
|
||||
model.addAttribute("sessionId", session);
|
||||
return "misc/mobile";
|
||||
}
|
||||
}
|
@ -1549,7 +1549,7 @@ validateSignature.cert.bits=bits
|
||||
####################
|
||||
cookieBanner.popUp.title=How we use Cookies
|
||||
cookieBanner.popUp.description.1=We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.
|
||||
cookieBanner.popUp.description.2=If you’d rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly.
|
||||
cookieBanner.popUp.description.2=If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly.
|
||||
cookieBanner.popUp.acceptAllBtn=Okay
|
||||
cookieBanner.popUp.acceptNecessaryBtn=No Thanks
|
||||
cookieBanner.popUp.showPreferencesBtn=Manage preferences
|
||||
@ -1565,7 +1565,7 @@ cookieBanner.preferencesModal.description.2=Stirling PDF cannot—and will never
|
||||
cookieBanner.preferencesModal.description.3=Your privacy and trust are at the core of what we do.
|
||||
cookieBanner.preferencesModal.necessary.title.1=Strictly Necessary Cookies
|
||||
cookieBanner.preferencesModal.necessary.title.2=Always Enabled
|
||||
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can’t be turned off.
|
||||
cookieBanner.preferencesModal.necessary.description=These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off.
|
||||
cookieBanner.preferencesModal.analytics.title=Analytics
|
||||
cookieBanner.preferencesModal.analytics.description=These cookies help us understand how our tools are being used, so we can focus on building the features our community values most. Rest assured—Stirling PDF cannot and will never track the content of the documents you work with.
|
||||
|
||||
@ -1604,3 +1604,30 @@ fakeScan.blur=Blur
|
||||
fakeScan.noise=Noise
|
||||
fakeScan.yellowish=Yellowish (simulate old paper)
|
||||
fakeScan.resolution=Resolution (DPI)
|
||||
|
||||
#scan-upload
|
||||
scan-upload.title=Scan and Upload
|
||||
scan-upload.description=Scan a document or photo with your phone and send it directly to this browser.
|
||||
scan-upload.instructions.title=Scan this QR code with your phone
|
||||
scan-upload.instructions.text=Use your phone's camera to scan the QR code, then take a photo of your document.
|
||||
scan-upload.session=Session ID:
|
||||
scan-upload.result=Scan Result
|
||||
scan-upload.download=Download
|
||||
scan-upload.new-scan=New Scan
|
||||
|
||||
#mobile
|
||||
mobile.title=Mobile Scanner
|
||||
mobile.align-document=Align your document within the frame
|
||||
mobile.retake=Retake
|
||||
mobile.upload=Upload
|
||||
mobile.uploading=Uploading...
|
||||
mobile.success=Success!
|
||||
mobile.success-message=Your scan has been successfully uploaded
|
||||
mobile.camera-access=Camera Access Required
|
||||
mobile.camera-permission=Please allow access to your camera to scan documents
|
||||
mobile.grant-access=Grant Access
|
||||
|
||||
#home.scan-upload
|
||||
home.scan-upload.title=Scan and Upload
|
||||
home.scan-upload.desc=Scan a document with your phone and send it to your browser
|
||||
scan-upload.tags=scan,upload,photo,camera,mobile
|
415
src/main/resources/static/css/mobile-scanner.css
Normal file
415
src/main/resources/static/css/mobile-scanner.css
Normal file
@ -0,0 +1,415 @@
|
||||
/* Mobile Scanner CSS */
|
||||
|
||||
body {
|
||||
background: var(--md-sys-color-background, #000);
|
||||
color: var(--md-sys-color-on-background, #fff);
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: 100vh; /* Fix height to prevent scrolling */
|
||||
position: fixed; /* Prevent body scrolling */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 10px;
|
||||
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.5);
|
||||
}
|
||||
|
||||
.input-method-tabs {
|
||||
display: flex;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(var(--md-sys-color-on-background, 255, 255, 255), 0.1);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.input-tab {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--md-sys-color-on-background, #fff);
|
||||
font-weight: 500;
|
||||
transition: background 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-tab.active {
|
||||
background: var(--md-sys-color-primary, #4CAF50);
|
||||
color: var(--md-sys-color-on-primary, #fff);
|
||||
}
|
||||
|
||||
.file-upload-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 70vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.file-upload-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border: 2px dashed var(--md-sys-color-outline, rgba(var(--md-sys-color-on-background, 255, 255, 255), 0.5));
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.file-upload-label:hover {
|
||||
border-color: var(--md-sys-color-primary, #4CAF50);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
color: var(--md-sys-color-primary, #4CAF50);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
color: var(--md-sys-color-on-background, #fff);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.file-preview-container {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.container, .review-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 50px); /* Account for header */
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
overflow: hidden; /* Prevent container scrolling */
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: var(--md-sys-color-surface-container, #222);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
max-height: 70vh;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#camera-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.controls, .review-controls {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.capture-btn, .review-btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.capture-btn {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
position: relative;
|
||||
border: 3px solid rgba(255,255,255,0.5);
|
||||
margin: auto;
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.capture-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.review-container {
|
||||
display: none;
|
||||
padding-bottom: 80px; /* Make room for batch preview */
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.preview-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.batch-btn {
|
||||
background: var(--md-sys-color-secondary, #4285F4);
|
||||
color: var(--md-sys-color-on-secondary, white);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-counter {
|
||||
font-size: 14px;
|
||||
color: var(--md-sys-color-on-background, white);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.batch-preview {
|
||||
position: fixed;
|
||||
bottom: 80px; /* Positioned above the camera controls */
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.8);
|
||||
padding: 10px;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
z-index: 6; /* Below the controls (z-index: 10) */
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.7);
|
||||
color: var(--md-sys-color-on-background, #fff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.batch-preview.active {
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.batch-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-batch-btn {
|
||||
background: var(--md-sys-color-primary, #4CAF50);
|
||||
color: var(--md-sys-color-on-primary, white);
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--md-sys-color-error, #f44336);
|
||||
color: var(--md-sys-color-on-error, white);
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-thumbnails {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 10px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.batch-thumbnail {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.batch-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove-thumbnail {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.6);
|
||||
color: var(--md-sys-color-on-error, white);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#capture-preview {
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
background: var(--md-sys-color-surface-container, #222);
|
||||
max-height: 70vh;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.retake-btn {
|
||||
background: var(--md-sys-color-error, #f44336);
|
||||
color: var(--md-sys-color-on-error, white);
|
||||
border-radius: 25px;
|
||||
padding: 12px 20px;
|
||||
width: 45%;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
background: var(--md-sys-color-primary, #4CAF50);
|
||||
color: var(--md-sys-color-on-primary, white);
|
||||
border-radius: 25px;
|
||||
padding: 12px 20px;
|
||||
width: 45%;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.spinner, .success-message, .camera-error {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.7);
|
||||
}
|
||||
|
||||
.spinner-border {
|
||||
border: 0.25em solid var(--md-sys-color-on-background, white);
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.7);
|
||||
color: var(--md-sys-color-on-background, white);
|
||||
}
|
||||
|
||||
.success-message i {
|
||||
font-size: 60px;
|
||||
color: var(--md-sys-color-primary, #4CAF50);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
background: rgba(var(--md-elevation-shadow-color-rgb, 0, 0, 0), 0.9);
|
||||
color: var(--md-sys-color-on-background, white);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Add more polished styling */
|
||||
.spinner-text {
|
||||
margin-top: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.success-message h2 {
|
||||
font-weight: 500;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.success-message p {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
119
src/main/resources/static/css/scan-upload.css
Normal file
119
src/main/resources/static/css/scan-upload.css
Normal file
@ -0,0 +1,119 @@
|
||||
/* Scan Upload CSS */
|
||||
|
||||
.scan-container {
|
||||
margin: 20px auto;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
color: var(--md-sys-color-on-surface, #000);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
color: var(--md-sys-color-on-surface, #000);
|
||||
}
|
||||
|
||||
#qrcode-container, .scan-result-container {
|
||||
margin: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-gallery {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--md-sys-color-surface-container, #fff);
|
||||
box-shadow: 0 2px 5px rgba(var(--md-elevation-shadow-color-rgb), 0.2);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.image-card:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.gallery-image {
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.image-action-btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.session-id, .status-msg {
|
||||
margin-top: 10px;
|
||||
font-family: monospace;
|
||||
background: var(--md-sys-color-surface-container-low, #f8f9fa);
|
||||
color: var(--md-sys-color-on-surface, #000);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
background-color: var(--md-sys-color-surface-variant, #f0f0f0);
|
||||
border-left: 4px solid var(--md-sys-color-primary, #0d6efd);
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
color: var(--md-sys-color-on-surface-variant, #000);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
color: var(--md-sys-color-on-surface-variant, #000);
|
||||
}
|
||||
|
||||
/* Ensure download button has proper colors */
|
||||
#download-btn {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
#new-scan-btn {
|
||||
background-color: var(--md-sys-color-secondary);
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scan-container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
#qrcode-container, .scan-result-container {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
@ -16,8 +16,10 @@ function setLanguageForDropdown(dropdownClass) {
|
||||
|
||||
function updateUrlWithLanguage(languageCode) {
|
||||
const currentURL = new URL(window.location.href);
|
||||
currentURL.searchParams.set('lang', languageCode);
|
||||
window.location.href = currentURL.toString();
|
||||
if (currentURL.searchParams.get('lang') !== languageCode) {
|
||||
currentURL.searchParams.set('lang', languageCode);
|
||||
window.location.href = currentURL.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDropdownItemClick(event) {
|
||||
@ -32,15 +34,25 @@ function handleDropdownItemClick(event) {
|
||||
}
|
||||
|
||||
function checkUserLanguage(defaultLocale) {
|
||||
const currentLanguageInDOM = document.documentElement.getAttribute('data-language');
|
||||
const currentURL = new URL(window.location.href);
|
||||
const langParam = currentURL.searchParams.get('lang');
|
||||
|
||||
if (
|
||||
!localStorage.getItem('languageCode') ||
|
||||
document.documentElement.getAttribute('data-language') != defaultLocale
|
||||
currentLanguageInDOM !== defaultLocale ||
|
||||
langParam !== defaultLocale
|
||||
) {
|
||||
localStorage.setItem('languageCode', defaultLocale);
|
||||
updateUrlWithLanguage(defaultLocale);
|
||||
|
||||
if (langParam !== defaultLocale) {
|
||||
currentURL.searchParams.set('lang', defaultLocale);
|
||||
window.location.href = currentURL.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function initLanguageSettings() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setLanguageForDropdown('.lang_dropdown-item');
|
||||
|
65
src/main/resources/static/js/scan-upload-direct.js
Normal file
65
src/main/resources/static/js/scan-upload-direct.js
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Backward compatibility script for scan-upload-direct.js
|
||||
* This loads the modular version
|
||||
*/
|
||||
|
||||
// Add async script loading
|
||||
function loadScript(src, async = true) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.async = async;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Load the modules asynchronously
|
||||
async function loadModules() {
|
||||
try {
|
||||
console.info('Loading scan-upload modules via compatibility layer...');
|
||||
|
||||
// If using type="module" is not supported, load individual scripts
|
||||
if (typeof window.initScanUploadPC !== 'function' &&
|
||||
typeof window.initScanUploadMobile !== 'function') {
|
||||
|
||||
// Load the necessary scripts
|
||||
await loadScript('/js/scan-upload/logger.js', true);
|
||||
await loadScript('/js/scan-upload/peer-connection.js', true);
|
||||
await loadScript('/js/scan-upload/camera.js', true);
|
||||
await loadScript('/js/scan-upload/scan-upload.js', true);
|
||||
|
||||
console.info('Modules loaded via compatibility layer');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading modules:', error);
|
||||
alert('Failed to load scan-upload modules. Please try refreshing the page.');
|
||||
}
|
||||
}
|
||||
|
||||
// Load scripts on page load
|
||||
document.addEventListener('DOMContentLoaded', loadModules);
|
||||
|
||||
// Forward function calls to the module implementations
|
||||
window.initScanUploadPC = function() {
|
||||
if (typeof window.initScanUploadPC === 'function') {
|
||||
return window.initScanUploadPC();
|
||||
} else {
|
||||
console.error('ScanUpload module not loaded correctly');
|
||||
}
|
||||
};
|
||||
|
||||
window.initScanUploadMobile = function() {
|
||||
if (typeof window.initScanUploadMobile === 'function') {
|
||||
return window.initScanUploadMobile();
|
||||
} else {
|
||||
console.error('ScanUpload module not loaded correctly');
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compatibility stubs
|
||||
window.generateRandomId = function() { return 'xxxx-xxxx-xxxx-xxxx'.replace(/[x]/g, () => (Math.random() * 16 | 0).toString(16)); };
|
||||
window.updateStatus = function(message) { console.log(message); };
|
||||
window.showError = function(message) { alert("Upload Error: " + message); };
|
||||
window.logDebug = function(message) { console.log("LOG:", message); };
|
496
src/main/resources/static/js/scan-upload/camera.js
Normal file
496
src/main/resources/static/js/scan-upload/camera.js
Normal file
@ -0,0 +1,496 @@
|
||||
/**
|
||||
* Camera handling module for the mobile scanner
|
||||
*/
|
||||
import Logger from './logger.js';
|
||||
import PeerConnection from './peer-connection.js';
|
||||
|
||||
const Camera = {
|
||||
stream: null,
|
||||
capturedImageData: null,
|
||||
capturedImages: [],
|
||||
|
||||
/**
|
||||
* Initialize the camera and file upload on mobile device
|
||||
*/
|
||||
init: function() {
|
||||
// Initialize camera
|
||||
this.initCamera();
|
||||
|
||||
// Set up tab switching
|
||||
this.setupTabs();
|
||||
|
||||
// Set up file upload handling
|
||||
this.setupFileUpload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize the camera
|
||||
*/
|
||||
initCamera: function() {
|
||||
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
|
||||
.then(stream => {
|
||||
this.stream = stream;
|
||||
// Camera stream started
|
||||
document.getElementById('camera-view').srcObject = stream;
|
||||
this.setupCameraControls();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Camera error:", err);
|
||||
document.getElementById('camera-error').style.display = 'flex';
|
||||
Logger.showError("Camera access denied or unavailable");
|
||||
|
||||
// Automatically switch to file upload if camera fails
|
||||
this.switchToFileUpload();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up tab switching between camera and file upload
|
||||
*/
|
||||
setupTabs: function() {
|
||||
const cameraTab = document.getElementById('camera-tab');
|
||||
const fileTab = document.getElementById('file-tab');
|
||||
|
||||
if (cameraTab && fileTab) {
|
||||
cameraTab.addEventListener('click', () => this.switchToCamera());
|
||||
fileTab.addEventListener('click', () => this.switchToFileUpload());
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch to camera view
|
||||
*/
|
||||
switchToCamera: function() {
|
||||
document.getElementById('camera-tab').classList.add('active');
|
||||
document.getElementById('file-tab').classList.remove('active');
|
||||
document.getElementById('camera-container').style.display = 'flex';
|
||||
document.getElementById('file-container').style.display = 'none';
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch to file upload view
|
||||
*/
|
||||
switchToFileUpload: function() {
|
||||
document.getElementById('file-tab').classList.add('active');
|
||||
document.getElementById('camera-tab').classList.remove('active');
|
||||
document.getElementById('file-container').style.display = 'flex';
|
||||
document.getElementById('camera-container').style.display = 'none';
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up event listeners for camera controls
|
||||
*/
|
||||
setupCameraControls: function() {
|
||||
const captureBtn = document.getElementById('capture-button');
|
||||
if (captureBtn) {
|
||||
captureBtn.onclick = () => this.captureImage();
|
||||
}
|
||||
|
||||
const uploadBtn = document.getElementById('upload-button');
|
||||
if (uploadBtn) {
|
||||
uploadBtn.onclick = () => this.uploadImage();
|
||||
} else {
|
||||
console.warn('Upload button not found');
|
||||
}
|
||||
|
||||
const retakeBtn = document.getElementById('retake-button');
|
||||
if (retakeBtn) {
|
||||
retakeBtn.onclick = () => this.retakeImage();
|
||||
}
|
||||
|
||||
// Add batch-related controls
|
||||
const addToBatchBtn = document.getElementById('add-to-batch-btn');
|
||||
if (addToBatchBtn) {
|
||||
addToBatchBtn.onclick = () => this.addToBatch();
|
||||
}
|
||||
|
||||
const uploadBatchBtn = document.getElementById('upload-batch-btn');
|
||||
if (uploadBatchBtn) {
|
||||
uploadBatchBtn.onclick = () => this.uploadBatch();
|
||||
}
|
||||
|
||||
const clearBatchBtn = document.getElementById('clear-batch-btn');
|
||||
if (clearBatchBtn) {
|
||||
clearBatchBtn.onclick = () => this.clearBatch();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up file upload functionality
|
||||
*/
|
||||
setupFileUpload: function() {
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const filePreview = document.getElementById('file-preview');
|
||||
const filePreviewContainer = document.getElementById('file-preview-container');
|
||||
const cancelFileBtn = document.getElementById('cancel-file-btn');
|
||||
const uploadFileBtn = document.getElementById('upload-file-btn');
|
||||
const addFileToBatchBtn = document.getElementById('add-file-to-batch-btn');
|
||||
|
||||
if (fileInput) {
|
||||
fileInput.onchange = (e) => {
|
||||
const files = e.target.files;
|
||||
if (files.length > 0) {
|
||||
// Handle multiple files if supported
|
||||
if (files.length === 1) {
|
||||
// Single file - show preview
|
||||
const file = files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
filePreview.src = e.target.result;
|
||||
this.capturedImageData = e.target.result;
|
||||
filePreviewContainer.style.display = 'block';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
// Multiple files - add all to batch
|
||||
this.addFilesToBatch(files);
|
||||
fileInput.value = ''; // Reset input
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (cancelFileBtn) {
|
||||
cancelFileBtn.onclick = () => {
|
||||
fileInput.value = '';
|
||||
filePreview.src = '';
|
||||
this.capturedImageData = null;
|
||||
filePreviewContainer.style.display = 'none';
|
||||
};
|
||||
}
|
||||
|
||||
if (uploadFileBtn) {
|
||||
uploadFileBtn.onclick = () => {
|
||||
if (this.capturedImageData) {
|
||||
this.uploadImage();
|
||||
} else {
|
||||
Logger.showError("No image selected. Please select an image first.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (addFileToBatchBtn) {
|
||||
addFileToBatchBtn.onclick = () => {
|
||||
if (this.capturedImageData) {
|
||||
// Add current file to batch
|
||||
this.addFileToBatch();
|
||||
} else {
|
||||
Logger.showError("No image selected. Please select an image first.");
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add current file to batch
|
||||
*/
|
||||
addFileToBatch: function() {
|
||||
if (!this.capturedImageData) {
|
||||
Logger.showError("No image selected. Please select an image first.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current image to the batch
|
||||
this.capturedImages.push(this.capturedImageData);
|
||||
|
||||
// Update batch counter
|
||||
this.updateBatchCounter();
|
||||
|
||||
// Add thumbnail to batch preview
|
||||
this.addThumbnail(this.capturedImageData);
|
||||
|
||||
// Show batch preview if it's the first image
|
||||
if (this.capturedImages.length === 1) {
|
||||
document.getElementById('batch-preview').classList.add('active');
|
||||
}
|
||||
|
||||
// Reset file input and preview
|
||||
const fileInput = document.getElementById('file-input');
|
||||
const filePreview = document.getElementById('file-preview');
|
||||
const filePreviewContainer = document.getElementById('file-preview-container');
|
||||
|
||||
if (fileInput) fileInput.value = '';
|
||||
if (filePreview) filePreview.src = '';
|
||||
if (filePreviewContainer) filePreviewContainer.style.display = 'none';
|
||||
|
||||
this.capturedImageData = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add multiple files to batch
|
||||
* @param {FileList} files - List of files to add
|
||||
*/
|
||||
addFilesToBatch: function(files) {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
// Show loading indicator
|
||||
const loadingMessage = document.createElement('div');
|
||||
loadingMessage.className = 'loading-message';
|
||||
loadingMessage.textContent = 'Processing files...';
|
||||
document.body.appendChild(loadingMessage);
|
||||
|
||||
// Process each file
|
||||
let processed = 0;
|
||||
Array.from(files).forEach(file => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
// Add to batch
|
||||
this.capturedImages.push(e.target.result);
|
||||
this.addThumbnail(e.target.result);
|
||||
|
||||
processed++;
|
||||
if (processed === files.length) {
|
||||
// All files processed
|
||||
document.body.removeChild(loadingMessage);
|
||||
|
||||
// Update counter and show batch
|
||||
this.updateBatchCounter();
|
||||
document.getElementById('batch-preview').classList.add('active');
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Capture an image from the camera
|
||||
*/
|
||||
captureImage: function() {
|
||||
// Capture button clicked
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const video = document.getElementById('camera-view');
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
|
||||
this.capturedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
document.getElementById('capture-preview').src = this.capturedImageData;
|
||||
|
||||
document.querySelector('.container').style.display = 'none';
|
||||
document.getElementById('review-container').style.display = 'flex';
|
||||
|
||||
// Image captured
|
||||
},
|
||||
|
||||
/**
|
||||
* Add the current image to the batch
|
||||
*/
|
||||
addToBatch: function() {
|
||||
if (!this.capturedImageData) {
|
||||
Logger.showError("No image captured. Please take a picture first.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add current image to the batch
|
||||
this.capturedImages.push(this.capturedImageData);
|
||||
|
||||
// Update batch counter
|
||||
this.updateBatchCounter();
|
||||
|
||||
// Add thumbnail to batch preview
|
||||
this.addThumbnail(this.capturedImageData);
|
||||
|
||||
// Show batch preview if it's the first image
|
||||
if (this.capturedImages.length === 1) {
|
||||
document.getElementById('batch-preview').classList.add('active');
|
||||
}
|
||||
|
||||
// Return to camera view
|
||||
this.retakeImage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the batch counter
|
||||
*/
|
||||
updateBatchCounter: function() {
|
||||
const counter = document.getElementById('batch-counter');
|
||||
const count = this.capturedImages.length;
|
||||
counter.textContent = count + (count === 1 ? ' image' : ' images');
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a thumbnail to the batch preview
|
||||
* @param {string} dataUrl - Image data URL
|
||||
*/
|
||||
addThumbnail: function(dataUrl) {
|
||||
const container = document.getElementById('batch-thumbnails');
|
||||
const index = this.capturedImages.length - 1;
|
||||
|
||||
const thumbnail = document.createElement('div');
|
||||
thumbnail.className = 'batch-thumbnail';
|
||||
thumbnail.dataset.index = index;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = dataUrl;
|
||||
img.alt = 'Thumbnail';
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'remove-thumbnail';
|
||||
removeBtn.innerHTML = '×';
|
||||
removeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.removeFromBatch(index);
|
||||
};
|
||||
|
||||
thumbnail.appendChild(img);
|
||||
thumbnail.appendChild(removeBtn);
|
||||
container.appendChild(thumbnail);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an image from the batch
|
||||
* @param {number} index - Index of the image to remove
|
||||
*/
|
||||
removeFromBatch: function(index) {
|
||||
// Remove from array
|
||||
this.capturedImages.splice(index, 1);
|
||||
|
||||
// Update batch counter
|
||||
this.updateBatchCounter();
|
||||
|
||||
// Refresh thumbnails
|
||||
this.refreshThumbnails();
|
||||
|
||||
// Hide batch preview if no images left
|
||||
if (this.capturedImages.length === 0) {
|
||||
document.getElementById('batch-preview').classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all images from the batch
|
||||
*/
|
||||
clearBatch: function() {
|
||||
this.capturedImages = [];
|
||||
this.updateBatchCounter();
|
||||
document.getElementById('batch-thumbnails').innerHTML = '';
|
||||
document.getElementById('batch-preview').classList.remove('active');
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh all thumbnails in the batch preview
|
||||
*/
|
||||
refreshThumbnails: function() {
|
||||
const container = document.getElementById('batch-thumbnails');
|
||||
container.innerHTML = '';
|
||||
|
||||
this.capturedImages.forEach((dataUrl, index) => {
|
||||
this.addThumbnail(dataUrl);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload all captured images
|
||||
*/
|
||||
uploadImage: function() {
|
||||
// Check if we have images in the batch or just the current preview
|
||||
if (this.capturedImages.length > 0) {
|
||||
// Upload all images in the batch
|
||||
this.uploadBatch();
|
||||
} else if (this.capturedImageData) {
|
||||
// Upload just the current image
|
||||
PeerConnection.sendImage(this.capturedImageData);
|
||||
} else {
|
||||
Logger.showError("No images captured. Please take pictures first.");
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload all images in the batch
|
||||
*/
|
||||
uploadBatch: function() {
|
||||
if (this.capturedImages.length === 0) {
|
||||
Logger.showError("Batch is empty. Please add images first.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show spinner
|
||||
document.getElementById('spinner').style.display = 'flex';
|
||||
|
||||
// Send each image sequentially
|
||||
const totalImages = this.capturedImages.length;
|
||||
let uploadedCount = 0;
|
||||
|
||||
const sendNextImage = (index) => {
|
||||
if (index >= this.capturedImages.length) {
|
||||
// All images sent, send completion notification
|
||||
PeerConnection.connection.send({ type: 'batch-complete' });
|
||||
|
||||
// Hide spinner and show success message
|
||||
setTimeout(() => {
|
||||
document.getElementById('spinner').style.display = 'none';
|
||||
document.getElementById('success-message').style.display = 'flex';
|
||||
|
||||
// Close window after success
|
||||
setTimeout(() => window.close(), 3000);
|
||||
}, 1000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update spinner text
|
||||
const spinnerText = document.querySelector('.spinner-text');
|
||||
spinnerText.textContent = `Uploading ${index + 1}/${totalImages}`;
|
||||
|
||||
// Send image
|
||||
const dataUrl = this.capturedImages[index];
|
||||
|
||||
try {
|
||||
// Using the peer connection directly for more control
|
||||
if (PeerConnection.connection && PeerConnection.connection.open) {
|
||||
PeerConnection.connection.send({ type: 'scan-image', data: dataUrl });
|
||||
|
||||
// Wait a bit before sending next image to prevent overwhelming the connection
|
||||
setTimeout(() => sendNextImage(index + 1), 500);
|
||||
} else {
|
||||
// Connection not ready yet, set up connection first
|
||||
PeerConnection.connection = PeerConnection.peer.connect(PeerConnection.sessionId, { reliable: true });
|
||||
|
||||
PeerConnection.connection.on('open', () => {
|
||||
PeerConnection.connection.send({ type: 'scan-image', data: dataUrl });
|
||||
setTimeout(() => sendNextImage(index + 1), 500);
|
||||
});
|
||||
|
||||
PeerConnection.connection.on('error', (err) => {
|
||||
console.error("Connection error:", err);
|
||||
Logger.showError("Upload failed: " + (err.message || "unknown error"));
|
||||
|
||||
// Try to continue with remaining images
|
||||
setTimeout(() => sendNextImage(index + 1), 1000);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Send error:", err);
|
||||
Logger.showError("Failed to send image: " + (err.message || "unknown error"));
|
||||
|
||||
// Try to continue with remaining images
|
||||
setTimeout(() => sendNextImage(index + 1), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
// Start sending images
|
||||
sendNextImage(0);
|
||||
},
|
||||
|
||||
/**
|
||||
* Switch back to camera view for retaking the image
|
||||
*/
|
||||
retakeImage: function() {
|
||||
document.querySelector('.container').style.display = 'flex';
|
||||
document.getElementById('review-container').style.display = 'none';
|
||||
// Retake selected
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop the camera stream
|
||||
*/
|
||||
stop: function() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default Camera;
|
79
src/main/resources/static/js/scan-upload/image-utils.js
Normal file
79
src/main/resources/static/js/scan-upload/image-utils.js
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Utility functions for handling images in the scan upload feature
|
||||
*/
|
||||
|
||||
const ImageUtils = {
|
||||
/**
|
||||
* Download all images as a ZIP file
|
||||
* @param {NodeList} images - Collection of image elements
|
||||
*/
|
||||
downloadAllAsZip: function(images) {
|
||||
if (!images || images.length === 0) {
|
||||
console.warn('No images to download');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load JSZip dynamically
|
||||
const script = document.createElement('script');
|
||||
script.src = '/js/thirdParty/jszip.min.js';
|
||||
script.onload = () => {
|
||||
this.createZip(images);
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error('Failed to load JSZip');
|
||||
alert('Failed to load ZIP library. Please try downloading images individually.');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create ZIP file with images
|
||||
* @param {NodeList} images - Collection of image elements
|
||||
*/
|
||||
createZip: function(images) {
|
||||
try {
|
||||
// JSZip is now loaded globally
|
||||
const zip = new JSZip();
|
||||
const timestamp = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Add each image to the zip
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const img = images[i];
|
||||
// Extract base64 data from data URL
|
||||
const dataUrl = img.src;
|
||||
const base64Data = dataUrl.split(',')[1];
|
||||
|
||||
// Add to zip
|
||||
zip.file(`scan-${i+1}.jpg`, base64Data, {base64: true});
|
||||
}
|
||||
|
||||
// Generate the zip file
|
||||
zip.generateAsync({type: 'blob'}).then((blob) => {
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `scans-${timestamp}.zip`;
|
||||
|
||||
// Trigger download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating ZIP file:', error);
|
||||
alert('Failed to create ZIP file. Please try downloading images individually.');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert a data URL to a Blob
|
||||
* @param {string} url - Data URL
|
||||
* @returns {Promise<Blob>} - Promise resolving to a Blob
|
||||
*/
|
||||
urlToBlob: function(url) {
|
||||
return fetch(url).then(response => response.blob());
|
||||
}
|
||||
};
|
||||
|
||||
export default ImageUtils;
|
39
src/main/resources/static/js/scan-upload/logger.js
Normal file
39
src/main/resources/static/js/scan-upload/logger.js
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Simplified logger module for the scan-upload functionality
|
||||
*/
|
||||
const Logger = {
|
||||
/**
|
||||
* Log a debug message to the console (disabled in production)
|
||||
* @param {string} message - Message to log
|
||||
*/
|
||||
debug: function(message) {
|
||||
// Debug logging disabled
|
||||
},
|
||||
|
||||
/**
|
||||
* Update status message in the UI
|
||||
* @param {string} message - Status message to display
|
||||
*/
|
||||
updateStatus: function(message) {
|
||||
const el = document.getElementById('status-message');
|
||||
if (el) {
|
||||
el.textContent = message;
|
||||
el.style.display = message ? 'block' : 'none';
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an error message
|
||||
* @param {string} message - Error message to display
|
||||
*/
|
||||
showError: function(message) {
|
||||
this.updateStatus("Error: " + message);
|
||||
|
||||
// Only show alerts for critical errors
|
||||
if (message.includes('failed') || message.includes('denied')) {
|
||||
alert("Upload Error: " + message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default Logger;
|
260
src/main/resources/static/js/scan-upload/peer-connection.js
Normal file
260
src/main/resources/static/js/scan-upload/peer-connection.js
Normal file
@ -0,0 +1,260 @@
|
||||
/**
|
||||
* WebRTC peer connection module for scan-upload functionality
|
||||
*/
|
||||
import Logger from './logger.js';
|
||||
|
||||
const PeerConnection = {
|
||||
peer: null,
|
||||
connection: null,
|
||||
sessionId: null,
|
||||
isReceiver: false,
|
||||
|
||||
/**
|
||||
* Generate a random session ID
|
||||
* @returns {string} Random session ID
|
||||
*/
|
||||
generateRandomId: function() {
|
||||
return 'xxxx-xxxx-xxxx-xxxx'.replace(/[x]/g, () => (Math.random() * 16 | 0).toString(16));
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize a peer connection
|
||||
* @param {string} id - Peer ID
|
||||
* @param {boolean} isReceiver - Whether this peer is receiving images
|
||||
*/
|
||||
init: function(id, isReceiver) {
|
||||
this.sessionId = id;
|
||||
this.isReceiver = isReceiver;
|
||||
|
||||
this.peer = new Peer(id, {
|
||||
config: {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
{ urls: 'turn:numb.viagenie.ca', credential: 'muazkh', username: 'webrtc@live.com' },
|
||||
{ urls: 'turn:192.158.29.39:3478?transport=udp', credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', username: '28224511:1379330808' },
|
||||
{ urls: 'turn:192.158.29.39:3478?transport=tcp', credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', username: '28224511:1379330808' },
|
||||
{ urls: 'turn:turn.bistri.com:80', credential: 'homeo', username: 'homeo' },
|
||||
{ urls: 'turn:turn.anyfirewall.com:443?transport=tcp', credential: 'webrtc', username: 'webrtc' }
|
||||
]
|
||||
},
|
||||
debug: 2
|
||||
});
|
||||
|
||||
// Peer created
|
||||
|
||||
this.setupEventListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Set up event listeners for the peer connection
|
||||
*/
|
||||
setupEventListeners: function() {
|
||||
this.peer.on('disconnected', () => {
|
||||
console.warn("PeerJS disconnected");
|
||||
Logger.showError("Disconnected from PeerJS server");
|
||||
});
|
||||
|
||||
this.peer.on('close', () => {
|
||||
console.log("PeerJS connection closed");
|
||||
});
|
||||
|
||||
if (this.isReceiver) {
|
||||
this.peer.on('connection', conn => {
|
||||
this.connection = conn;
|
||||
// Receiver connected
|
||||
|
||||
conn.on('data', data => {
|
||||
if (data.type === 'scan-image') {
|
||||
Logger.updateStatus('Scan received');
|
||||
document.getElementById('qrcode-container').style.display = 'none';
|
||||
|
||||
// Create new image container for each received image
|
||||
this.addImageToGallery(data.data);
|
||||
|
||||
document.querySelector('.scan-result-container').style.display = 'block';
|
||||
}
|
||||
else if (data.type === 'batch-complete') {
|
||||
Logger.updateStatus('Batch upload complete');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.peer.on('error', (err) => {
|
||||
console.error("PeerJS Error:", err);
|
||||
// PeerJS error occurred
|
||||
Logger.showError("PeerJS error: " + err.type + " - " + (err.message || "unknown"));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Send an image to the peer
|
||||
* @param {string} dataUrl - Image data URL
|
||||
* @param {boolean} closeAfterSend - Whether to close the window after sending (default: true)
|
||||
*/
|
||||
sendImage: function(dataUrl, closeAfterSend = true) {
|
||||
// Attempt to send image
|
||||
|
||||
if (!this.connection || !this.connection.open) {
|
||||
// Establishing connection to peer
|
||||
this.connection = this.peer.connect(this.sessionId, { reliable: true });
|
||||
|
||||
let waited = 0;
|
||||
const pollInterval = setInterval(() => {
|
||||
if (this.connection.open) {
|
||||
clearInterval(pollInterval);
|
||||
return;
|
||||
}
|
||||
waited += 1000;
|
||||
if (waited >= 10000) {
|
||||
clearInterval(pollInterval);
|
||||
console.warn('Connection still not open after 10s. Forcing retry.');
|
||||
Logger.showError('Connection failed after retry. Check network or switch to same Wi-Fi.');
|
||||
} else {
|
||||
// Still waiting for connection
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.connection.on('open', () => {
|
||||
// Connection opened
|
||||
this.connection.send({ type: 'scan-image', data: dataUrl });
|
||||
|
||||
// Only show success and close window if this is the final image
|
||||
if (closeAfterSend) {
|
||||
document.getElementById('spinner').style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
document.getElementById('spinner').style.display = 'none';
|
||||
document.getElementById('success-message').style.display = 'flex';
|
||||
setTimeout(() => window.close(), 3000);
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
this.connection.on('error', (err) => {
|
||||
clearInterval(pollInterval);
|
||||
console.error("Connection error:", err);
|
||||
// Connection error occurred
|
||||
Logger.showError("Upload failed: " + (err.message || "unknown error"));
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
// Using existing connection
|
||||
this.connection.send({ type: 'scan-image', data: dataUrl });
|
||||
|
||||
// Only show success and close window if this is the final image
|
||||
if (closeAfterSend) {
|
||||
document.getElementById('spinner').style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
document.getElementById('spinner').style.display = 'none';
|
||||
document.getElementById('success-message').style.display = 'flex';
|
||||
setTimeout(() => window.close(), 3000);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Send error:", err);
|
||||
// Send error occurred
|
||||
Logger.showError("Failed to send image: " + (err.message || "unknown error"));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset the peer connection
|
||||
*/
|
||||
reset: function() {
|
||||
if (this.peer) {
|
||||
this.peer.destroy();
|
||||
}
|
||||
this.connection = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a QR code with the session URL
|
||||
* @param {string} sessionId - Session ID
|
||||
* @param {HTMLElement} container - QR code container element
|
||||
*/
|
||||
generateQRCode: function(sessionId, container) {
|
||||
const url = `${window.location.origin}/mobile?session=${sessionId}`;
|
||||
|
||||
// Clear previous QR code if any
|
||||
container.innerHTML = '';
|
||||
|
||||
new QRCode(container, {
|
||||
text: url,
|
||||
width: 256,
|
||||
height: 256,
|
||||
colorDark: "#000",
|
||||
colorLight: "#fff",
|
||||
correctLevel: QRCode.CorrectLevel.H
|
||||
});
|
||||
|
||||
// Add link below QR code
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.textContent = url;
|
||||
a.style.color = '#0af';
|
||||
a.style.display = 'block';
|
||||
a.style.marginTop = '10px';
|
||||
container.appendChild(a);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new image to the gallery
|
||||
* @param {string} dataUrl - Image data URL
|
||||
*/
|
||||
addImageToGallery: function(dataUrl) {
|
||||
const galleryContainer = document.getElementById('image-gallery');
|
||||
if (!galleryContainer) {
|
||||
console.error('Gallery container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new card for the image
|
||||
const card = document.createElement('div');
|
||||
card.className = 'image-card';
|
||||
|
||||
// Create image element
|
||||
const img = document.createElement('img');
|
||||
img.src = dataUrl;
|
||||
img.className = 'gallery-image';
|
||||
img.alt = 'Scanned Image';
|
||||
|
||||
// Create download button
|
||||
const downloadBtn = document.createElement('a');
|
||||
downloadBtn.href = dataUrl;
|
||||
downloadBtn.className = 'btn btn-primary btn-sm image-action-btn';
|
||||
downloadBtn.textContent = 'Download';
|
||||
downloadBtn.download = `scan-${new Date().getTime()}.jpg`;
|
||||
|
||||
// Create delete button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-danger btn-sm image-action-btn';
|
||||
deleteBtn.textContent = 'Remove';
|
||||
deleteBtn.onclick = function() {
|
||||
card.remove();
|
||||
|
||||
// If all images are removed, hide the gallery
|
||||
if (galleryContainer.children.length === 0) {
|
||||
document.querySelector('.scan-result-container').style.display = 'none';
|
||||
document.getElementById('qrcode-container').style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
// Create actions container
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'image-actions';
|
||||
actions.appendChild(downloadBtn);
|
||||
actions.appendChild(deleteBtn);
|
||||
|
||||
// Add all elements to the card
|
||||
card.appendChild(img);
|
||||
card.appendChild(actions);
|
||||
|
||||
// Add the card to the gallery
|
||||
galleryContainer.appendChild(card);
|
||||
}
|
||||
};
|
||||
|
||||
export default PeerConnection;
|
83
src/main/resources/static/js/scan-upload/scan-upload.js
Normal file
83
src/main/resources/static/js/scan-upload/scan-upload.js
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Main scan upload module
|
||||
*/
|
||||
import Logger from './logger.js';
|
||||
import PeerConnection from './peer-connection.js';
|
||||
import Camera from './camera.js';
|
||||
import ImageUtils from './image-utils.js';
|
||||
|
||||
/**
|
||||
* Initialize scan upload on PC/desktop
|
||||
*/
|
||||
function initScanUploadPC() {
|
||||
// Set as receiver (desktop)
|
||||
PeerConnection.init(PeerConnection.generateRandomId(), true);
|
||||
|
||||
// Display session ID
|
||||
document.getElementById('session-id').textContent = PeerConnection.sessionId;
|
||||
|
||||
// Generate QR code
|
||||
const qrcodeContainer = document.getElementById('qrcode');
|
||||
PeerConnection.generateQRCode(PeerConnection.sessionId, qrcodeContainer);
|
||||
|
||||
// Set up buttons
|
||||
document.getElementById('new-scan-btn').onclick = resetConnection;
|
||||
document.getElementById('download-all-btn').onclick = downloadAllImages;
|
||||
|
||||
// Desktop initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize scan upload on mobile
|
||||
*/
|
||||
function initScanUploadMobile() {
|
||||
// Initialize mobile app
|
||||
|
||||
// Extract session ID from URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sessionId = urlParams.get('session');
|
||||
|
||||
if (!sessionId) {
|
||||
Logger.showError('Missing session ID in URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize peer connection (not as receiver)
|
||||
PeerConnection.init(PeerConnection.generateRandomId(), false);
|
||||
PeerConnection.sessionId = sessionId;
|
||||
|
||||
// Initialize camera
|
||||
Camera.init();
|
||||
|
||||
// Mobile initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the connection
|
||||
*/
|
||||
function resetConnection() {
|
||||
PeerConnection.reset();
|
||||
initScanUploadPC();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all images as a ZIP
|
||||
*/
|
||||
function downloadAllImages() {
|
||||
const images = document.querySelectorAll('.gallery-image');
|
||||
if (images.length === 0) {
|
||||
alert('No images to download');
|
||||
return;
|
||||
}
|
||||
|
||||
ImageUtils.downloadAllAsZip(images);
|
||||
}
|
||||
|
||||
// Export functions for global use
|
||||
window.initScanUploadPC = initScanUploadPC;
|
||||
window.initScanUploadMobile = initScanUploadMobile;
|
||||
|
||||
// Initialize based on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// This will be initialized from the HTML page based on which view is loaded
|
||||
});
|
7
src/main/resources/static/js/thirdParty/eventemitter.min.js
vendored
Normal file
7
src/main/resources/static/js/thirdParty/eventemitter.min.js
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/*!
|
||||
* EventEmitter v5.2.9 - git.io/ee
|
||||
* Unlicense - http://unlicense.org/
|
||||
* Oliver Caldwell - https://oli.me.uk/
|
||||
* @preserve
|
||||
*/
|
||||
!function(e){"use strict";function t(){}function n(e,t){for(var n=e.length;n--;)if(e[n].listener===t)return n;return-1}function r(e){return function(){return this[e].apply(this,arguments)}}function i(e){return"function"==typeof e||e instanceof RegExp||!(!e||"object"!=typeof e)&&i(e.listener)}var o=t.prototype,s=e.EventEmitter;o.getListeners=function(e){var t,n,r=this._getEvents();if(e instanceof RegExp){t={};for(n in r)r.hasOwnProperty(n)&&e.test(n)&&(t[n]=r[n])}else t=r[e]||(r[e]=[]);return t},o.flattenListeners=function(e){var t,n=[];for(t=0;t<e.length;t+=1)n.push(e[t].listener);return n},o.getListenersAsObject=function(e){var t,n=this.getListeners(e);return n instanceof Array&&(t={},t[e]=n),t||n},o.addListener=function(e,t){if(!i(t))throw new TypeError("listener must be a function");var r,o=this.getListenersAsObject(e),s="object"==typeof t;for(r in o)o.hasOwnProperty(r)&&-1===n(o[r],t)&&o[r].push(s?t:{listener:t,once:!1});return this},o.on=r("addListener"),o.addOnceListener=function(e,t){return this.addListener(e,{listener:t,once:!0})},o.once=r("addOnceListener"),o.defineEvent=function(e){return this.getListeners(e),this},o.defineEvents=function(e){for(var t=0;t<e.length;t+=1)this.defineEvent(e[t]);return this},o.removeListener=function(e,t){var r,i,o=this.getListenersAsObject(e);for(i in o)o.hasOwnProperty(i)&&-1!==(r=n(o[i],t))&&o[i].splice(r,1);return this},o.off=r("removeListener"),o.addListeners=function(e,t){return this.manipulateListeners(!1,e,t)},o.removeListeners=function(e,t){return this.manipulateListeners(!0,e,t)},o.manipulateListeners=function(e,t,n){var r,i,o=e?this.removeListener:this.addListener,s=e?this.removeListeners:this.addListeners;if("object"!=typeof t||t instanceof RegExp)for(r=n.length;r--;)o.call(this,t,n[r]);else for(r in t)t.hasOwnProperty(r)&&(i=t[r])&&("function"==typeof i?o.call(this,r,i):s.call(this,r,i));return this},o.removeEvent=function(e){var t,n=typeof e,r=this._getEvents();if("string"===n)delete r[e];else if(e instanceof RegExp)for(t in r)r.hasOwnProperty(t)&&e.test(t)&&delete r[t];else delete this._events;return this},o.removeAllListeners=r("removeEvent"),o.emitEvent=function(e,t){var n,r,i,o,s=this.getListenersAsObject(e);for(o in s)if(s.hasOwnProperty(o))for(n=s[o].slice(0),i=0;i<n.length;i++)r=n[i],!0===r.once&&this.removeListener(e,r.listener),r.listener.apply(this,t||[])===this._getOnceReturnValue()&&this.removeListener(e,r.listener);return this},o.trigger=r("emitEvent"),o.emit=function(e){var t=Array.prototype.slice.call(arguments,1);return this.emitEvent(e,t)},o.setOnceReturnValue=function(e){return this._onceReturnValue=e,this},o._getOnceReturnValue=function(){return!this.hasOwnProperty("_onceReturnValue")||this._onceReturnValue},o._getEvents=function(){return this._events||(this._events={})},t.noConflict=function(){return e.EventEmitter=s,t},"function"==typeof define&&define.amd?define(function(){return t}):"object"==typeof module&&module.exports?module.exports=t:e.EventEmitter=t}(this||{});
|
50
src/main/resources/static/js/thirdParty/peerjs-wrapper.js
vendored
Normal file
50
src/main/resources/static/js/thirdParty/peerjs-wrapper.js
vendored
Normal file
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* PeerJS Wrapper - Simple initialization helper
|
||||
*/
|
||||
|
||||
// Check if PeerJS is loaded properly
|
||||
if (typeof Peer === 'undefined') {
|
||||
console.error('PeerJS is not defined. Make sure peerjs.min.js is loaded before this script.');
|
||||
} else {
|
||||
console.log('PeerJS loaded successfully, version:', Peer.version || 'unknown');
|
||||
}
|
||||
|
||||
// Create a global connection manager
|
||||
window.PeerManager = {
|
||||
createPeer: function(id, options) {
|
||||
try {
|
||||
// Default options with cloud server
|
||||
var defaultOptions = {};
|
||||
|
||||
// Merge options
|
||||
var finalOptions = options ? Object.assign({}, defaultOptions, options) : defaultOptions;
|
||||
|
||||
console.log('Creating PeerJS instance with ID:', id);
|
||||
return new Peer(id, finalOptions);
|
||||
} catch (err) {
|
||||
console.error('Error creating PeerJS instance:', err);
|
||||
|
||||
// Return a dummy peer that won't crash the application
|
||||
return {
|
||||
id: id,
|
||||
on: function(event, callback) {
|
||||
if (event === 'error') {
|
||||
setTimeout(function() {
|
||||
callback(new Error('Could not create PeerJS instance'));
|
||||
}, 100);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
connect: function() {
|
||||
return {
|
||||
on: function() { return this; },
|
||||
send: function() {}
|
||||
};
|
||||
},
|
||||
destroy: function() {}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('PeerJS wrapper initialized successfully');
|
2
src/main/resources/static/js/thirdParty/peerjs.min.js
vendored
Normal file
2
src/main/resources/static/js/thirdParty/peerjs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/static/js/thirdParty/qrcode.min.js
vendored
Normal file
1
src/main/resources/static/js/thirdParty/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
src/main/resources/static/js/thirdParty/sockjs.min.js
vendored
Normal file
3
src/main/resources/static/js/thirdParty/sockjs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
src/main/resources/static/js/thirdParty/stomp.min.js
vendored
Normal file
8
src/main/resources/static/js/thirdParty/stomp.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -277,6 +277,9 @@
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntryCustom :: navbarEntry('split-pdf-by-chapters', '/images/split-chapters.svg#icon-split-chapters', 'home.splitPdfByChapters.title', 'home.splitPdfByChapters.desc', 'splitPdfByChapters.tags', 'advance')}">
|
||||
</div>
|
||||
<div
|
||||
th:replace="~{fragments/navbarEntry :: navbarEntry('scan-upload', 'qr_code_scanner', 'home.scan-upload.title', 'home.scan-upload.desc', 'scan-upload.tags', 'advance')}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</th:block>
|
@ -1,5 +1,5 @@
|
||||
<div th:fragment="navbar" class="mx-auto" style="position: sticky; top:0; z-index:10000; width:100%">
|
||||
<script th:src="@{'/js/languageSelection.js'}"></script>
|
||||
<script th:src="@{'/js/languageSelection.js'}"></script>
|
||||
<script th:src="@{'/js/navbar.js'}"></script>
|
||||
<script th:src="@{'/js/additionalLanguageCode.js'}"></script>
|
||||
<script th:inline="javascript">
|
||||
|
100
src/main/resources/templates/misc/mobile.html
Normal file
100
src/main/resources/templates/misc/mobile.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{mobile.title}, header=#{mobile.title})}"></th:block>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<link rel="stylesheet" th:href="@{'/css/mobile-scanner.css'}">
|
||||
<script th:src="@{'/js/thirdParty/peerjs.min.js'}"></script>
|
||||
<script type="module" th:src="@{'/js/scan-upload/scan-upload.js'}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-header">
|
||||
<div class="input-method-tabs">
|
||||
<button id="camera-tab" class="input-tab active">Camera</button>
|
||||
<button id="file-tab" class="input-tab">File Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="camera-container" class="container">
|
||||
<div class="camera-container">
|
||||
<video id="camera-view" autoplay playsinline></video>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="capture-btn" id="capture-button" th:title="#{mobile.align-document}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="file-container" class="container" style="display:none;">
|
||||
<div class="file-upload-container">
|
||||
<label for="file-input" class="file-upload-label">
|
||||
<span class="upload-icon">+</span>
|
||||
<span class="upload-text">Select Image(s)</span>
|
||||
</label>
|
||||
<input type="file" id="file-input" accept="image/*" multiple style="display:none;">
|
||||
<div id="file-preview-container" class="file-preview-container" style="display:none;">
|
||||
<img id="file-preview" class="file-preview" src="" alt="File Preview">
|
||||
<div class="file-actions">
|
||||
<button id="cancel-file-btn" class="btn btn-secondary">Cancel</button>
|
||||
<button id="add-file-to-batch-btn" class="btn btn-success">Add to Batch</button>
|
||||
<button id="upload-file-btn" class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-container" id="review-container">
|
||||
<div class="preview-header">
|
||||
<h3>Preview</h3>
|
||||
<div class="preview-controls">
|
||||
<button id="add-to-batch-btn" class="batch-btn">Add to Batch</button>
|
||||
<span id="batch-counter" class="batch-counter">0 images</span>
|
||||
</div>
|
||||
</div>
|
||||
<img id="capture-preview" src="" alt="Preview">
|
||||
<div class="review-controls">
|
||||
<button class="review-btn retake-btn" id="retake-button" th:text="#{mobile.retake}">Retake</button>
|
||||
<button class="review-btn upload-btn" id="upload-button" th:text="#{mobile.upload}">Upload All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-preview" id="batch-preview">
|
||||
<div class="batch-header">
|
||||
<h4>Batch Images</h4>
|
||||
<div class="batch-actions">
|
||||
<button id="upload-batch-btn" class="upload-batch-btn">Upload All</button>
|
||||
<button id="clear-batch-btn" class="clear-btn">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="batch-thumbnails" class="batch-thumbnails"></div>
|
||||
</div>
|
||||
<div class="spinner" id="spinner">
|
||||
<div class="spinner-border"></div>
|
||||
<div class="spinner-text" th:text="#{mobile.uploading}">Uploading...</div>
|
||||
</div>
|
||||
<div class="success-message" id="success-message">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
<h2 th:text="#{mobile.success}">Success!</h2>
|
||||
<p th:text="#{mobile.success-message}">Scan uploaded</p>
|
||||
</div>
|
||||
<div class="camera-error" id="camera-error">
|
||||
<i class="bi bi-camera-video-off" style="font-size: 48px; margin-bottom: 20px;"></i>
|
||||
<h2 th:text="#{mobile.camera-access}">Camera Access Required</h2>
|
||||
<p th:text="#{mobile.camera-permission}">Please allow camera access and reload this page over HTTPS.</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof initScanUploadMobile === 'function') {
|
||||
initScanUploadMobile();
|
||||
// Ensure debug log div is visible if script didn't add it
|
||||
const log = document.getElementById('mobile-debug-log');
|
||||
if (log) log.style.display = 'block';
|
||||
} else {
|
||||
console.error('ScanUpload module not loaded correctly');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
52
src/main/resources/templates/misc/scan-upload.html
Normal file
52
src/main/resources/templates/misc/scan-upload.html
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<th:block th:insert="~{fragments/common :: head(title=#{scan-upload.title}, header=#{scan-upload.title})}"></th:block>
|
||||
<link rel="stylesheet" th:href="@{'/css/scan-upload.css'}">
|
||||
<script th:src="@{'/js/thirdParty/qrcode.min.js'}"></script>
|
||||
<script th:src="@{'/js/thirdParty/peerjs.min.js'}"></script>
|
||||
<script type="module" th:src="@{'/js/scan-upload/scan-upload.js'}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="page-container">
|
||||
<div id="content-wrap">
|
||||
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
|
||||
<div class="container scan-container">
|
||||
<div class="card mt-5">
|
||||
<div class="card-header">
|
||||
<h2 th:text="#{scan-upload.title}">Scan and Upload</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info scan-info">
|
||||
<p th:text="#{scan-upload.description}">Scan with your phone and upload directly to this browser.</p>
|
||||
</div>
|
||||
<div id="status-message" class="status-msg"></div>
|
||||
<div id="qrcode-container">
|
||||
<h4 th:text="#{scan-upload.instructions.title}">Scan this QR code with your phone</h4>
|
||||
<div id="qrcode"></div>
|
||||
<div class="session-id"><span th:text="#{scan-upload.session}">Session ID:</span> <span id="session-id"></span></div>
|
||||
</div>
|
||||
<div class="scan-result-container" style="display:none">
|
||||
<h4 th:text="#{scan-upload.result}">Scan Results</h4>
|
||||
<div id="image-gallery" class="image-gallery"></div>
|
||||
<div class="actions">
|
||||
<button id="new-scan-btn" class="btn btn-secondary" th:text="#{scan-upload.new-scan}">New Scan</button>
|
||||
<a id="download-all-btn" class="btn btn-primary" >Download All (ZIP)</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof initScanUploadPC === 'function') {
|
||||
initScanUploadPC();
|
||||
} else {
|
||||
console.error('ScanUpload module not loaded correctly');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Loading…
x
Reference in New Issue
Block a user