SuperSeller3000/templates/admin/manual_ingest.html.twig
Simon Kuehn 0453d0542c fix: file picker and drop zone on ingest page
- Use <label for="..."> with form.image.vars.id instead of .click() on
  hidden input — display:none blocks programmatic click in some browsers
- Add drag-and-drop to the search photo drop zone (dragover/drop)
- Make extra photos input opacity:0/absolute so label trigger works too
- Camera fallback references correct searchInput variable via closure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 09:58:36 +00:00

459 lines
21 KiB
Twig
Raw 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.

{% extends '@EasyAdmin/page/content.html.twig' %}
{% block page_title %}Artikel einlesen{% endblock %}
{% block main %}
{% if catalogNumber %}
<div class="alert alert-success d-flex align-items-center gap-3 mb-4" role="alert">
<i class="fa fa-check-circle fa-2x"></i>
<div>
<strong>In der Pipeline —</strong>
<span class="fs-4 ms-2 font-monospace fw-bold">{{ catalogNumber }}</span>
<div class="small mt-1">
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\AIPipelineJobCrudController').setAction('index').generateUrl() }}">Job-Status ansehen →</a>
</div>
</div>
</div>
{% endif %}
{# ── Camera Modal ─────────────────────────────────────────────────── #}
<div class="modal fade" id="cameraModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cameraModalLabel"><i class="fa fa-camera me-2"></i>Foto aufnehmen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 bg-black text-center" style="min-height:300px;display:flex;align-items:center;justify-content:center;">
<video id="camera-video" autoplay playsinline muted
style="max-width:100%;max-height:420px;display:block;"></video>
<canvas id="camera-canvas" style="display:none;max-width:100%;max-height:420px;"></canvas>
<div id="camera-error" class="text-white p-4" style="display:none;">
<i class="fa fa-exclamation-circle fa-2x mb-2 d-block text-warning"></i>
Kein Kamerazugriff. Bitte Datei hochladen.
</div>
</div>
<div class="modal-footer justify-content-center gap-2">
<button id="camera-capture-btn" class="btn btn-primary btn-lg" style="display:none;">
<i class="fa fa-circle me-1" style="color:#e74c3c;"></i> Aufnehmen
</button>
<button id="camera-retake-btn" class="btn btn-secondary" style="display:none;">
<i class="fa fa-redo me-1"></i> Wiederholen
</button>
<button id="camera-confirm-btn" class="btn btn-success btn-lg" style="display:none;">
<i class="fa fa-check me-1"></i> Verwenden
</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
</div>
</div>
</div>
</div>
<div class="row g-4">
{# ── Left: form ───────────────────────────────────────────────── #}
<div class="col-lg-7">
{{ form_start(form, {'attr': {'enctype': 'multipart/form-data', 'id': 'ingest-form', 'novalidate': 'novalidate'}}) }}
<div class="card mb-3">
<div class="card-header"><h5 class="mb-0"><i class="fa fa-tag me-2"></i>Artikelinfo</h5></div>
<div class="card-body">
<div class="mb-3">
{{ form_label(form.articleType) }}
{{ form_widget(form.articleType) }}
{{ form_errors(form.articleType) }}
</div>
<div class="row g-3">
<div class="col-sm-6">
{{ form_label(form.condition) }}
{{ form_widget(form.condition) }}
</div>
<div class="col-sm-6">
{{ form_label(form.conditionNotes) }}
{{ form_widget(form.conditionNotes, {'attr': {'class': 'form-control'}}) }}
</div>
</div>
</div>
</div>
{# ── Search photo (mandatory) ─────────────────────────────── #}
{% set searchInputId = form.image.vars.id %}
<div class="card mb-3 border-primary">
<div class="card-header bg-primary bg-opacity-10">
<h5 class="mb-0 text-primary">
<i class="fa fa-search me-2"></i>Erkennungs-Foto
<span class="badge bg-danger ms-2 fs-6">Pflicht</span>
</h5>
<div class="text-muted small mt-1">Typenschild / Aufkleber mit Modell &amp; Seriennummer</div>
</div>
<div class="card-body">
{# File input — opacity:0 so label-click still works in all browsers #}
{{ form_widget(form.image, {'attr': {'style': 'position:absolute;opacity:0;width:0;height:0;', 'tabindex': '-1', 'id': searchInputId}}) }}
{{ form_errors(form.image) }}
{# Drop zone / placeholder (also a drop target) #}
<div id="search-drop-zone" class="border rounded text-center p-4 text-muted bg-light mb-3"
style="cursor:pointer;transition:border-color .15s,background .15s;"
data-input="{{ searchInputId }}">
{# Preview (hidden until photo selected) #}
<div id="search-photo-preview" style="display:none;">
<img id="search-photo-img" src="" alt="Vorschau"
class="img-fluid rounded" style="max-height:220px;">
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="search-photo-clear">
<i class="fa fa-times me-1"></i>Entfernen
</button>
</div>
</div>
{# Empty state #}
<div id="search-photo-placeholder">
<i class="fa fa-image fa-3x mb-3 d-block"></i>
<span>Foto hier ablegen oder Schaltfläche nutzen</span>
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-primary flex-fill" id="btn-search-camera">
<i class="fa fa-camera me-2"></i>Kamera
</button>
{# label triggers the file input directly — no JS needed #}
<label for="{{ searchInputId }}" class="btn btn-outline-secondary flex-fill mb-0" style="cursor:pointer;">
<i class="fa fa-folder-open me-2"></i>Datei wählen
</label>
</div>
</div>
</div>
{# ── Additional photos (optional) ─────────────────────────── #}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-images me-2"></i>Weitere Fotos <span class="text-muted fw-normal fs-6">(optional)</span></h5>
</div>
<div class="card-body">
{# Plain HTML input — label trigger works without JS tricks #}
<input type="file" name="additional_photos[]" id="extra-photos-input"
accept="image/jpeg,image/png,image/webp" multiple
style="position:absolute;opacity:0;width:0;height:0;">
<div id="extra-photos-grid" class="d-flex flex-wrap gap-2 mb-3" style="min-height:60px;">
<div id="extra-photos-empty" class="text-muted small fst-italic d-flex align-items-center">
Keine weiteren Fotos hinzugefügt
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-primary flex-fill" id="btn-extra-camera">
<i class="fa fa-camera me-2"></i>Kamera
</button>
<label for="extra-photos-input" class="btn btn-outline-secondary flex-fill mb-0" style="cursor:pointer;">
<i class="fa fa-folder-open me-2"></i>Datei wählen
</label>
</div>
</div>
</div>
<button type="submit" class="btn btn-success btn-lg w-100" id="submit-btn">
<i class="fa fa-rocket me-2"></i>Zur KI-Pipeline senden
</button>
{{ form_end(form) }}
</div>
{# ── Right: AI config ─────────────────────────────────────────── #}
<div class="col-lg-5">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="fa fa-robot me-2"></i>KI-Konfiguration</h5>
<span class="badge bg-{{ aiConfig.backend == 'Mistral' ? 'primary' : 'secondary' }}">
{{ aiConfig.backend }} aktiv
</span>
</div>
<div class="card-body p-0">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<th class="ps-3 text-muted fw-normal" style="width:45%">Vision-Modell</th>
<td class="font-monospace">{{ aiConfig.vision_model }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Text-Modell</th>
<td class="font-monospace">{{ aiConfig.text_model }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Mistral Endpoint</th>
<td class="font-monospace small">{{ aiConfig.mistral_base_url }}</td>
</tr>
<tr>
<th class="ps-3 text-muted fw-normal">Mistral API-Key</th>
<td>
{% if aiConfig.mistral_key_set %}
<span class="badge bg-success me-1">gesetzt</span>
<span class="font-monospace small text-muted">{{ aiConfig.mistral_key_hint }}</span>
{% else %}
<span class="badge bg-danger">nicht gesetzt</span>
{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
<div class="card-footer">
<button id="btn-test-mistral" class="btn btn-outline-primary btn-sm w-100"
{% if not aiConfig.mistral_key_set %}disabled title="MISTRAL_API_KEY setzen"{% endif %}>
<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen
</button>
<div id="test-result" class="mt-3" style="display:none"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
/* ── Camera state ──────────────────────────────────────────── */
const video = document.getElementById('camera-video');
const canvas = document.getElementById('camera-canvas');
const errDiv = document.getElementById('camera-error');
const btnCapture = document.getElementById('camera-capture-btn');
const btnRetake = document.getElementById('camera-retake-btn');
const btnConfirm = document.getElementById('camera-confirm-btn');
let cameraStream = null;
let cameraTarget = null; // 'search' | 'extra'
let capturedBlob = null;
const modal = new bootstrap.Modal(document.getElementById('cameraModal'));
async function openCamera(target) {
cameraTarget = target;
capturedBlob = null;
canvas.style.display = 'none';
errDiv.style.display = 'none';
video.style.display = 'block';
btnCapture.style.display = 'none';
btnRetake.style.display = 'none';
btnConfirm.style.display = 'none';
modal.show();
try {
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 } },
});
video.srcObject = cameraStream;
await video.play();
btnCapture.style.display = 'inline-block';
} catch {
stopCamera();
video.style.display = 'none';
errDiv.style.display = 'block';
// Mobile fallback: trigger native camera input
if (cameraTarget === 'search') {
searchInput?.click();
} else {
document.getElementById('extra-photos-input').click();
}
modal.hide();
}
}
function stopCamera() {
cameraStream?.getTracks().forEach(t => t.stop());
cameraStream = null;
}
document.getElementById('cameraModal').addEventListener('hidden.bs.modal', stopCamera);
btnCapture.addEventListener('click', () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
stopCamera();
video.style.display = 'none';
canvas.style.display = 'block';
btnCapture.style.display = 'none';
btnRetake.style.display = 'inline-block';
btnConfirm.style.display = 'inline-block';
canvas.toBlob(b => { capturedBlob = b; }, 'image/jpeg', 0.92);
});
btnRetake.addEventListener('click', () => openCamera(cameraTarget));
btnConfirm.addEventListener('click', () => {
if (!capturedBlob) return;
const file = new File([capturedBlob], 'kamera-' + Date.now() + '.jpg', { type: 'image/jpeg' });
if (cameraTarget === 'search') {
setSearchPhoto(file);
} else {
addExtraPhoto(file);
}
modal.hide();
});
/* ── Search photo ──────────────────────────────────────────── */
const dropZone = document.getElementById('search-drop-zone');
const searchInput = document.getElementById(dropZone.dataset.input);
const searchPreview = document.getElementById('search-photo-preview');
const searchImg = document.getElementById('search-photo-img');
const searchHolder = document.getElementById('search-photo-placeholder');
document.getElementById('btn-search-camera').addEventListener('click', () => openCamera('search'));
document.getElementById('search-photo-clear').addEventListener('click', e => {
e.stopPropagation();
searchInput.value = '';
searchImg.src = '';
searchPreview.style.display = 'none';
searchHolder.style.display = 'block';
});
searchInput.addEventListener('change', () => {
if (searchInput.files[0]) setSearchPhoto(searchInput.files[0]);
});
// Drag & drop on the drop zone
dropZone.addEventListener('dragover', e => {
e.preventDefault();
dropZone.style.borderColor = '#0d6efd';
dropZone.style.background = '#e8f0fe';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.borderColor = '';
dropZone.style.background = '';
});
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.style.borderColor = '';
dropZone.style.background = '';
const file = e.dataTransfer.files[0];
if (file) setSearchPhoto(file);
});
function setSearchPhoto(file) {
const dt = new DataTransfer();
dt.items.add(file);
searchInput.files = dt.files;
searchImg.src = URL.createObjectURL(file);
searchPreview.style.display = 'block';
searchHolder.style.display = 'none';
dropZone.style.borderColor = '';
dropZone.style.background = '';
}
/* ── Extra photos ──────────────────────────────────────────── */
const extraInput = document.getElementById('extra-photos-input');
const extraGrid = document.getElementById('extra-photos-grid');
const extraEmpty = document.getElementById('extra-photos-empty');
let extraFiles = [];
document.getElementById('btn-extra-camera').addEventListener('click', () => openCamera('extra'));
document.getElementById('btn-extra-file').addEventListener('click', () => extraInput.click());
extraInput.addEventListener('change', () => {
for (const f of extraInput.files) addExtraPhoto(f);
extraInput.value = '';
});
function addExtraPhoto(file) {
extraFiles.push(file);
syncExtraInput();
renderExtraGrid();
}
function removeExtraPhoto(idx) {
extraFiles.splice(idx, 1);
syncExtraInput();
renderExtraGrid();
}
function syncExtraInput() {
const dt = new DataTransfer();
extraFiles.forEach(f => dt.items.add(f));
extraInput.files = dt.files;
}
function renderExtraGrid() {
// remove all thumbnail cards (keep the empty placeholder)
extraGrid.querySelectorAll('.extra-thumb').forEach(el => el.remove());
extraEmpty.style.display = extraFiles.length === 0 ? 'flex' : 'none';
extraFiles.forEach((file, idx) => {
const card = document.createElement('div');
card.className = 'extra-thumb position-relative';
card.style.cssText = 'width:90px;height:90px;';
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.className = 'w-100 h-100 rounded border object-fit-cover';
img.style.objectFit = 'cover';
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn btn-danger btn-sm position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
del.style.cssText = 'width:20px;height:20px;font-size:.65rem;line-height:1;border-radius:50%;transform:translate(40%,-40%);';
del.innerHTML = '×';
del.addEventListener('click', () => removeExtraPhoto(idx));
card.appendChild(img);
card.appendChild(del);
extraGrid.appendChild(card);
});
}
/* ── Submit validation ─────────────────────────────────────── */
document.getElementById('ingest-form').addEventListener('submit', e => {
if (!searchInput.files || searchInput.files.length === 0) {
e.preventDefault();
searchHolder.classList.add('border-danger');
searchHolder.innerHTML = '<i class="fa fa-exclamation-triangle fa-2x text-danger mb-2 d-block"></i>'
+ '<span class="text-danger fw-bold">Erkennungs-Foto ist Pflicht</span>';
searchHolder.style.display = 'block';
}
});
/* ── Mistral test ──────────────────────────────────────────── */
document.getElementById('btn-test-mistral')?.addEventListener('click', async function () {
const btn = this;
const resultDiv = document.getElementById('test-result');
btn.disabled = true;
btn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i>Teste…';
resultDiv.style.display = 'none';
try {
const resp = await fetch('{{ path('admin_ai_test') }}', { method: 'POST', body: new FormData() });
const data = await resp.json();
resultDiv.style.display = 'block';
resultDiv.innerHTML = renderTestResult(data);
} catch (err) {
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="alert alert-danger py-2 mb-0">Fehler: ' + err.message + '</div>';
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa fa-plug me-1"></i>Mistral-Verbindung testen';
}
});
function renderTestResult(data) {
return '<table class="table table-sm mb-0">'
+ modelRow('Text', data.text)
+ modelRow('Vision', data.vision)
+ '</table>';
}
function modelRow(label, r) {
if (r?.ok) {
return `<tr>
<td><span class="badge bg-success">OK</span> <strong>${label}</strong></td>
<td class="font-monospace small">${e(r.model)}</td>
<td class="text-muted small">${r.ms} ms</td>
</tr><tr><td colspan="3" class="ps-3 text-muted small">${e(r.response ?? '')}</td></tr>`;
}
return `<tr>
<td><span class="badge bg-danger">FAIL</span> <strong>${label}</strong></td>
<td colspan="2" class="font-monospace small text-danger">${e(r?.error ?? 'Unbekannter Fehler')}</td>
</tr>`;
}
function e(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
})();
</script>
{% endblock %}