improve photos

This commit is contained in:
O K
2025-11-25 11:00:26 +02:00
parent ff2dcdc0ee
commit 8e7141175a
6 changed files with 1004 additions and 719 deletions

View File

@@ -1,88 +1,557 @@
{**
This script block passes the unique, secure AJAX URL from the PHP controller to our JavaScript.
The 'javascript' escaper is crucial to prevent encoding issues.
**}
<script type="text/javascript">
var addLivePhotoAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
{* Pass URL to JS *}
<script>
var alpAjaxUrl = '{$ajax_url|escape:'javascript':'UTF-8'}';
</script>
<div class="panel">
<div class="panel" id="alp-app">
<div class="panel-heading">
<i class="icon-camera"></i> {l s='Live Photo Uploader' d='Modules.Addlivephoto.Admin'}
<i class="icon-camera"></i> {l s='Live Photo Scanner' d='Modules.Addlivephoto.Admin'}
</div>
<div class="container-fluid">
<div class="row">
<div class="col-lg-8 col-lg-offset-2">
<div class="row">
<div class="col-md-6 col-md-offset-3">
{* --- The New Unified Camera Interface --- *}
<div id="alp-camera-view" class="my-3">
<div id="alp-video-container" class="video-container">
{* The video feed will be attached here by JavaScript *}
<video id="alp-video" autoplay playsinline muted></video>
{* 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>
{* This overlay displays instructions and is the main tap target *}
<div id="alp-viewfinder-overlay">
<div id="alp-overlay-text"></div>
</div>
{* 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>
{* This canvas is used for capturing the frame but is not visible *}
<canvas id="alp-canvas" style="display: none;"></canvas>
{* 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>
{* --- Message Area (for non-critical feedback) --- *}
<div id="alp-message-area" class="alert" style="display: none; text-align: center;"></div>
{* --- Product Information (hidden by default) --- *}
<div id="alp-product-info" style="display: none;" class="card mt-4">
<div class="card-header bg-success text-white">
<h5 class="card-title mb-0">{l s='Product Found' d='Modules.Addlivephoto.Admin'}</h5>
{* 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>
<div class="card-body">
<p><strong>{l s='Name:' d='Modules.Addlivephoto.Admin'}</strong> <span id="alp-product-name"></span></p>
<p><strong>{l s='Prices:' d='Modules.Addlivephoto.Admin'}</strong> <span id="alp-product-prices"></span></p>
</div>
</div>
{* --- Existing Photos (hidden by default) --- *}
<div id="alp-existing-photos" style="display: none;" class="card mt-4">
<div class="card-header">
<h5 class="card-title mb-0">{l s='Existing Live Photos' d='Modules.Addlivephoto.Admin'}</h5>
</div>
<div class="card-body">
<div id="alp-photos-container" class="d-flex flex-wrap gap-2">
{* JavaScript will populate this area *}
</div>
</div>
</div>
{* --- Settings Section (at the bottom, out of the way) --- *}
<div class="card mt-4">
<div class="card-header">{l s='Camera Settings' d='Modules.Addlivephoto.Admin'}</div>
<div class="card-body">
<div class="form-group">
<label for="alp-camera-selector" class="control-label">{l s='Select Camera:' d='Modules.Addlivephoto.Admin'}</label>
<select id="alp-camera-selector" class="form-control"></select>
</div>
</div>
</div>
{* --- Manual Input Section (remains as a fallback) --- *}
<div id="alp-manual-input" class="card mt-3">
<div class="card-header">{l s='Or Enter Manually' d='Modules.Addlivephoto.Admin'}</div>
<div class="card-body">
<form id="alp-manual-form" class="form-inline">
<div class="form-group">
<label for="alp-manual-identifier" class="mr-2">{l s='Product ID or EAN13 Barcode:' d='Modules.Addlivephoto.Admin'}</label>
<input type="text" id="alp-manual-identifier" class="form-control mr-2" placeholder="e.g., 4006381333931">
</div>
<button type="submit" class="btn btn-default"><i class="icon-search"></i> {l s='Find Product' d='Modules.Addlivephoto.Admin'}</button>
</form>
</div>
</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>
</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>