557 lines
20 KiB
Smarty
557 lines
20 KiB
Smarty
{* Pass URL to JS *}
|
||
<script>
|
||
var alpAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
|
||
</script>
|
||
|
||
<div class="panel" id="alp-app">
|
||
<div class="panel-heading">
|
||
<i class="icon-camera"></i> {l s='Live Photo Scanner' d='Modules.Addlivephoto.Admin'}
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6 col-md-offset-3">
|
||
|
||
{* 1. CAMERA VIEW *}
|
||
<div id="alp-step-scan" class="text-center">
|
||
<div class="video-wrapper"
|
||
style="position: relative; background: #000; min-height: 300px; margin-bottom: 15px; overflow: hidden;">
|
||
<video id="alp-video" autoplay playsinline muted
|
||
style="width: 100%; height: 100%; object-fit: cover;"></video>
|
||
<canvas id="alp-canvas" style="display: none;"></canvas>
|
||
|
||
{* Camera Controls Overlay *}
|
||
<div id="alp-controls"
|
||
style="position: absolute; bottom: 20px; left: 0; width: 100%; display: flex; justify-content: center; gap: 20px; z-index: 10;">
|
||
{* Flash Button (Hidden by default until capability detected) *}
|
||
<button type="button" id="btn-torch" class="btn btn-default btn-circle" style="display:none;">
|
||
<i class="icon-bolt"></i>
|
||
</button>
|
||
|
||
{* Zoom Button (Hidden by default) *}
|
||
<button type="button" id="btn-zoom" class="btn btn-default btn-circle" style="display:none;">
|
||
1x
|
||
</button>
|
||
</div>
|
||
|
||
{* Overlay Message *}
|
||
<div id="alp-overlay"
|
||
style="position: absolute; top:0; left:0; width:100%; height:100%; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5); color: #fff; pointer-events: none;">
|
||
<span id="alp-status-text">Starting Camera...</span>
|
||
</div>
|
||
</div>
|
||
|
||
{* Manual Input Fallback *}
|
||
<form id="alp-manual-form" class="form-inline" style="margin-top: 10px;">
|
||
<div class="form-group">
|
||
<input type="text" id="alp-manual-input" class="form-control" placeholder="EAN13 or Product ID">
|
||
</div>
|
||
<button type="submit" class="btn btn-primary"><i class="icon-search"></i> Search</button>
|
||
</form>
|
||
</div>
|
||
|
||
{* 2. PRODUCT ACTIONS (Hidden initially) *}
|
||
<div id="alp-step-action" style="display: none;">
|
||
<div class="alert alert-info">
|
||
<strong>Product:</strong> <span id="alp-product-name"></span>
|
||
</div>
|
||
|
||
<div class="text-center" style="margin-bottom: 20px;">
|
||
<p class="text-muted">What are you photographing?</p>
|
||
<div class="btn-group-lg">
|
||
<button type="button" class="btn btn-success" id="btn-snap-expiry">
|
||
<i class="icon-calendar"></i> Expiry Date
|
||
</button>
|
||
<button type="button" class="btn btn-info" id="btn-snap-packaging">
|
||
<i class="icon-box"></i> Packaging
|
||
</button>
|
||
</div>
|
||
<br><br>
|
||
<button type="button" class="btn btn-default btn-sm" id="btn-reset">
|
||
<i class="icon-refresh"></i> Scan New Product
|
||
</button>
|
||
</div>
|
||
|
||
{* Existing Photos List *}
|
||
<div class="panel">
|
||
<div class="panel-heading">Existing Photos</div>
|
||
<div class="panel-body" id="alp-photo-list" style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||
{* Photos injected via JS *}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<style>
|
||
.video-wrapper {
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||
position: relative;
|
||
background: #000;
|
||
}
|
||
|
||
#alp-video {
|
||
width: 100%;
|
||
/* Ensure aspect ratio handles mobile screens well */
|
||
max-height: 60vh;
|
||
}
|
||
|
||
/* Circular Control Buttons */
|
||
.btn-circle {
|
||
width: 50px;
|
||
height: 50px;
|
||
border-radius: 50%;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255, 255, 255, 0.3);
|
||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||
color: #fff;
|
||
backdrop-filter: blur(4px);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.btn-circle:hover,
|
||
.btn-circle:active,
|
||
.btn-circle.active {
|
||
background: rgba(255, 255, 255, 0.9);
|
||
color: #333;
|
||
}
|
||
|
||
.btn-circle i {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.alp-thumb {
|
||
position: relative;
|
||
width: 100px;
|
||
height: 100px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
background: #f9f9f9;
|
||
}
|
||
|
||
.alp-thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.alp-thumb .badge {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
font-size: 10px;
|
||
text-align: center;
|
||
border-radius: 0;
|
||
padding: 3px;
|
||
}
|
||
|
||
.alp-thumb .btn-delete {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
width: 24px;
|
||
height: 24px;
|
||
background: rgba(255, 0, 0, 0.8);
|
||
color: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-weight: bold;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
// 1. Elements
|
||
const video = document.getElementById('alp-video');
|
||
const canvas = document.getElementById('alp-canvas');
|
||
const overlayText = document.getElementById('alp-status-text');
|
||
const stepScan = document.getElementById('alp-step-scan');
|
||
const stepAction = document.getElementById('alp-step-action');
|
||
|
||
// Controls
|
||
const btnTorch = document.getElementById('btn-torch');
|
||
const btnZoom = document.getElementById('btn-zoom');
|
||
|
||
// Product Data Elements
|
||
const productNameEl = document.getElementById('alp-product-name');
|
||
const photoListEl = document.getElementById('alp-photo-list');
|
||
|
||
// Buttons
|
||
const manualForm = document.getElementById('alp-manual-form');
|
||
const btnExpiry = document.getElementById('btn-snap-expiry');
|
||
const btnPkg = document.getElementById('btn-snap-packaging');
|
||
const btnReset = document.getElementById('btn-reset');
|
||
|
||
// State
|
||
let currentStream = null;
|
||
let videoTrack = null; // Store track for zoom/torch
|
||
let barcodeDetector = null;
|
||
let isScanning = false;
|
||
let currentProductId = null;
|
||
|
||
// Camera Features State
|
||
let zoomLevel = 1;
|
||
let torchState = false;
|
||
let capabilities = {};
|
||
|
||
// 2. Initialize
|
||
initBarcodeDetector();
|
||
startCamera();
|
||
|
||
// 3. Event Listeners
|
||
manualForm.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const val = document.getElementById('alp-manual-input').value.trim();
|
||
if (val) fetchProduct(val);
|
||
});
|
||
|
||
btnReset.addEventListener('click', resetApp);
|
||
|
||
btnExpiry.addEventListener('click', () => takePhoto('expiry'));
|
||
btnPkg.addEventListener('click', () => takePhoto('packaging'));
|
||
|
||
// Zoom Toggle
|
||
btnZoom.addEventListener('click', () => {
|
||
if (!videoTrack) return;
|
||
|
||
// Toggle between 1 and 2 (or max zoom)
|
||
zoomLevel = (zoomLevel === 1) ? 2 : 1;
|
||
|
||
// Check max zoom
|
||
if (capabilities.zoom) {
|
||
zoomLevel = Math.min(zoomLevel, capabilities.zoom.max);
|
||
}
|
||
|
||
try {
|
||
videoTrack.applyConstraints({
|
||
advanced: [{ zoom: zoomLevel }]
|
||
});
|
||
btnZoom.textContent = zoomLevel + 'x';
|
||
btnZoom.classList.toggle('active', zoomLevel > 1);
|
||
} catch (err) {
|
||
console.error('Zoom failed', err);
|
||
}
|
||
});
|
||
|
||
// Torch Toggle
|
||
btnTorch.addEventListener('click', () => {
|
||
if (!videoTrack) return;
|
||
|
||
torchState = !torchState;
|
||
|
||
try {
|
||
videoTrack.applyConstraints({
|
||
advanced: [{ torch: torchState }]
|
||
});
|
||
btnTorch.classList.toggle('active', torchState);
|
||
} catch (err) {
|
||
console.error('Torch failed', err);
|
||
// Fallback if failed
|
||
torchState = !torchState;
|
||
}
|
||
});
|
||
|
||
// --- Core Functions ---
|
||
|
||
async function initBarcodeDetector() {
|
||
if ('BarcodeDetector' in window) {
|
||
try {
|
||
const formats = await BarcodeDetector.getSupportedFormats();
|
||
if (formats.includes('ean_13')) {
|
||
barcodeDetector = new BarcodeDetector({ formats: ['ean_13'] });
|
||
console.log('BarcodeDetector ready');
|
||
} else {
|
||
overlayText.textContent = "EAN13 not supported by device hardware";
|
||
}
|
||
} catch (e) {
|
||
console.warn('BarcodeDetector error', e);
|
||
}
|
||
} else {
|
||
console.warn('BarcodeDetector API not supported');
|
||
overlayText.textContent = "Auto-scan not supported. Use manual search.";
|
||
}
|
||
}
|
||
|
||
async function startCamera() {
|
||
try {
|
||
// Try high res for better barcode scanning
|
||
const constraints = {
|
||
video: {
|
||
facingMode: { ideal: 'environment' },
|
||
width: { ideal: 1920 },
|
||
height: { ideal: 1080 },
|
||
// Zoom/Torch are usually "advanced" constraints, applied later
|
||
}
|
||
};
|
||
|
||
currentStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||
video.srcObject = currentStream;
|
||
|
||
// Get the video track to control Zoom/Torch
|
||
videoTrack = currentStream.getVideoTracks()[0];
|
||
|
||
// Check Capabilities (Zoom/Torch)
|
||
if (videoTrack.getCapabilities) {
|
||
capabilities = videoTrack.getCapabilities();
|
||
|
||
// Enable Torch Button if supported
|
||
if (capabilities.torch) {
|
||
btnTorch.style.display = 'flex';
|
||
}
|
||
|
||
// Enable Zoom Button if supported
|
||
if (capabilities.zoom) {
|
||
btnZoom.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
// Wait for video to be ready
|
||
video.onloadedmetadata = () => {
|
||
video.play();
|
||
document.getElementById('alp-overlay').style.display =
|
||
'none'; // Hide "Starting..." overlay
|
||
|
||
if (barcodeDetector) {
|
||
isScanning = true;
|
||
// Add a visual scanning line or text
|
||
const scanOverlay = document.createElement('div');
|
||
scanOverlay.id = 'scan-line';
|
||
scanOverlay.style.cssText =
|
||
'position:absolute; top:50%; left:10%; right:10%; height:2px; background:red; box-shadow:0 0 4px red; opacity:0.7;';
|
||
// Check if already exists
|
||
if (!document.getElementById('scan-line')) {
|
||
document.querySelector('.video-wrapper').appendChild(scanOverlay);
|
||
}
|
||
|
||
scanLoop();
|
||
}
|
||
};
|
||
} catch (err) {
|
||
console.error(err);
|
||
overlayText.textContent = "Camera Access Denied. Check permissions.";
|
||
document.getElementById('alp-overlay').style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
async function scanLoop() {
|
||
if (!isScanning || !barcodeDetector || currentProductId) return;
|
||
|
||
try {
|
||
const barcodes = await barcodeDetector.detect(video);
|
||
if (barcodes.length > 0) {
|
||
const code = barcodes[0].rawValue;
|
||
console.log("Barcode Detected:", code);
|
||
playSound(); // Optional feedback
|
||
|
||
isScanning = false; // Stop scanning immediately
|
||
fetchProduct(code);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
// Detection error (common while moving camera)
|
||
}
|
||
|
||
// Scan loop (optimized)
|
||
if (isScanning) {
|
||
requestAnimationFrame(scanLoop);
|
||
}
|
||
}
|
||
|
||
function playSound() {
|
||
// Simple beep
|
||
const context = new(window.AudioContext || window.webkitAudioContext)();
|
||
const oscillator = context.createOscillator();
|
||
oscillator.type = "sine";
|
||
oscillator.frequency.value = 800;
|
||
oscillator.connect(context.destination);
|
||
oscillator.start();
|
||
setTimeout(() => oscillator.stop(), 100);
|
||
}
|
||
|
||
function fetchProduct(identifier) {
|
||
// Remove scan line if exists
|
||
const line = document.getElementById('scan-line');
|
||
if (line) line.remove();
|
||
|
||
const btnSubmit = manualForm.querySelector('button');
|
||
const originalText = btnSubmit.innerHTML;
|
||
btnSubmit.disabled = true;
|
||
btnSubmit.innerHTML = 'Searching...';
|
||
|
||
const fd = new FormData();
|
||
fd.append('action', 'searchProduct');
|
||
fd.append('identifier', identifier);
|
||
|
||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
loadProductView(data.data);
|
||
} else {
|
||
alert(data.message || 'Product not found');
|
||
resetApp(); // Go back to scanning
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error(err);
|
||
alert('Network Error');
|
||
resetApp();
|
||
})
|
||
.finally(() => {
|
||
btnSubmit.disabled = false;
|
||
btnSubmit.innerHTML = originalText;
|
||
});
|
||
}
|
||
|
||
{literal}
|
||
function loadProductView(productData) {
|
||
currentProductId = productData.id_product;
|
||
// Smarty ignores the curly braces and ${} inside the literal tags
|
||
productNameEl.textContent = `[${productData.reference || ''}] ${productData.name}`;
|
||
|
||
renderPhotos(productData.existing_photos);
|
||
|
||
// Switch View
|
||
stepScan.style.display = 'none';
|
||
stepAction.style.display = 'block';
|
||
}
|
||
{/literal}
|
||
|
||
function takePhoto(type) {
|
||
if (!currentProductId) return;
|
||
|
||
// Use canvas to capture high-res frame
|
||
// Set canvas to video dimension for full resolution
|
||
canvas.width = video.videoWidth;
|
||
canvas.height = video.videoHeight;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||
|
||
// Convert to WebP (0.8 quality)
|
||
const dataUrl = canvas.toDataURL('image/webp', 0.8);
|
||
|
||
// Upload
|
||
const fd = new FormData();
|
||
fd.append('action', 'uploadImage');
|
||
fd.append('id_product', currentProductId);
|
||
fd.append('image_type', type);
|
||
fd.append('imageData', dataUrl);
|
||
|
||
// Visual feedback
|
||
const btn = (type === 'expiry') ? btnExpiry : btnPkg;
|
||
const originalText = btn.innerHTML;
|
||
btn.innerHTML = '<i class="icon-refresh icon-spin"></i> Saving...';
|
||
btn.disabled = true;
|
||
|
||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
addPhotoToDom(data.photo);
|
||
// Provide haptic feedback if available
|
||
if (navigator.vibrate) navigator.vibrate(200);
|
||
} else {
|
||
alert('Error: ' + data.message);
|
||
}
|
||
})
|
||
.catch(err => alert('Upload failed'))
|
||
.finally(() => {
|
||
btn.innerHTML = originalText;
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
|
||
function renderPhotos(photos) {
|
||
photoListEl.innerHTML = '';
|
||
if (photos && photos.length) {
|
||
photos.forEach(addPhotoToDom);
|
||
} else {
|
||
photoListEl.innerHTML = '<p class="text-muted" style="width:100%">No photos yet.</p>';
|
||
}
|
||
}
|
||
|
||
function addPhotoToDom(photo) {
|
||
// Remove "No photos" msg if exists
|
||
const emptyMsg = photoListEl.querySelector('p');
|
||
if (emptyMsg) emptyMsg.remove();
|
||
|
||
const div = document.createElement('div');
|
||
div.className = 'alp-thumb';
|
||
|
||
const badgeClass = (photo.type === 'expiry') ? 'badge-success' : 'badge-info';
|
||
{literal}
|
||
div.innerHTML = `
|
||
<a href="${photo.url}" target="_blank">
|
||
<img src="${photo.url}">
|
||
</a>
|
||
<span class="badge ${badgeClass}">${photo.type}</span>
|
||
<button class="btn-delete" onclick="deletePhoto(${currentProductId}, '${photo.name}', this)">×</button>
|
||
`;
|
||
{/literal}
|
||
|
||
photoListEl.prepend(div);
|
||
}
|
||
|
||
function resetApp() {
|
||
currentProductId = null;
|
||
document.getElementById('alp-manual-input').value = '';
|
||
stepAction.style.display = 'none';
|
||
stepScan.style.display = 'block';
|
||
|
||
// Reset controls
|
||
zoomLevel = 1;
|
||
if (videoTrack) {
|
||
try { videoTrack.applyConstraints({ advanced: [{ zoom: 1 }] }); } catch (e) {}
|
||
btnZoom.textContent = '1x';
|
||
btnZoom.classList.remove('active');
|
||
}
|
||
|
||
if (barcodeDetector) {
|
||
isScanning = true;
|
||
// Re-add scan line
|
||
const scanOverlay = document.createElement('div');
|
||
scanOverlay.id = 'scan-line';
|
||
scanOverlay.style.cssText =
|
||
'position:absolute; top:50%; left:10%; right:10%; height:2px; background:red; box-shadow:0 0 4px red; opacity:0.7;';
|
||
if (!document.getElementById('scan-line')) {
|
||
document.querySelector('.video-wrapper').appendChild(scanOverlay);
|
||
}
|
||
scanLoop();
|
||
}
|
||
}
|
||
|
||
// Expose delete function globally
|
||
window.deletePhoto = function(idProduct, imgName, btnEl) {
|
||
if (!confirm('Delete this photo?')) return;
|
||
|
||
const fd = new FormData();
|
||
fd.append('action', 'deleteImage');
|
||
fd.append('id_product', idProduct);
|
||
fd.append('image_name', imgName);
|
||
|
||
fetch(window.alpAjaxUrl, { method: 'POST', body: fd })
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
btnEl.closest('.alp-thumb').remove();
|
||
} else {
|
||
alert('Error deleting');
|
||
}
|
||
});
|
||
};
|
||
});
|
||
</script> |