Files
addlivephoto/views/templates/admin/uploader.tpl
2025-11-25 11:00:26 +02:00

557 lines
20 KiB
Smarty
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{* 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>