SuperSeller3000/templates/admin/photo_management.html.twig
Simon Kuehn 59b34a780e
Some checks are pending
CI / test (push) Waiting to run
feat: show recognition photo on photo management page
Displays the original pipeline search photo (storedPhotoPath from
AIPipelineJob input_data) as a read-only card above the editable photos.
The photo is fetched only if the file still exists on disk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:00:53 +00:00

285 lines
14 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 %}Fotos — {{ article.inventoryNumber }}{% endblock %}
{% block main %}
<div class="d-flex align-items-center gap-3 mb-4">
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\ArticleCrudController').setAction('detail').setEntityId(article.id).generateUrl() }}"
class="btn btn-sm btn-outline-secondary">
<i class="fa fa-arrow-left me-1"></i>Zum Artikel
</a>
<h3 class="mb-0">
<i class="fa fa-images me-2 text-muted"></i>
Fotos verwalten
<span class="text-muted fw-normal fs-5 ms-2">{{ article.inventoryNumber }}</span>
</h3>
</div>
{% for label, messages in app.flashes %}
{% for message in messages %}
<div class="alert alert-{{ label == 'success' ? 'success' : (label == 'danger' ? 'danger' : 'warning') }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endfor %}
<div class="row g-4">
{# ── Search / recognition photo (read-only) ────────────────────────── #}
{% if searchPhotoFilename %}
<div class="col-12">
<div class="card border-secondary">
<div class="card-header bg-secondary bg-opacity-10 d-flex align-items-center gap-2">
<i class="fa fa-search text-secondary"></i>
<span class="fw-semibold">Erkennungs-Foto</span>
<span class="text-muted small">(vom KI-Pipeline-Einlesen — unveränderlich)</span>
</div>
<div class="card-body d-flex align-items-center gap-4">
<img src="{{ path('admin_photo_serve', {filename: searchPhotoFilename}) }}"
alt="Erkennungs-Foto"
class="rounded border"
style="max-height:160px;max-width:200px;object-fit:contain;">
<div class="text-muted small">
<i class="fa fa-info-circle me-1"></i>
Dieses Foto wurde beim Einlesen für die KI-Erkennung (Typenschild) verwendet.
Es kann hier nicht geändert werden.
</div>
</div>
</div>
</div>
{% endif %}
{# ── Current photos ────────────────────────────────────────────────── #}
<div class="col-12">
<div class="card">
<div class="card-header d-flex align-items-center justify-content-between">
<h5 class="mb-0"><i class="fa fa-photo-film me-2"></i>Aktuelle Fotos</h5>
<span class="badge bg-secondary">{{ photos|length }} Fotos</span>
</div>
<div class="card-body">
{% if photos is empty %}
<p class="text-muted text-center py-4 mb-0">
<i class="fa fa-image fa-2x d-block mb-2"></i>Noch keine Fotos vorhanden
</p>
{% else %}
{# Drag-reorder form #}
<form method="POST" action="{{ path('admin_article_photos_reorder', {id: article.id}) }}" id="reorder-form">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<div id="photo-grid" class="row g-3 mb-3">
{% for photo in photos %}
<div class="col-6 col-md-4 col-lg-3 photo-card" data-id="{{ photo.id }}">
<input type="hidden" name="order[]" value="{{ photo.id }}">
<div class="card h-100 position-relative {{ photo.main ? 'border-primary border-2' : '' }}">
{% if photo.main %}
<span class="badge bg-primary position-absolute top-0 start-0 m-2" style="z-index:2;">
<i class="fa fa-star me-1"></i>Hauptfoto
</span>
{% endif %}
<img src="{{ path('public_photo', {filename: photo.filename}) }}"
alt="Foto {{ loop.index }}"
class="card-img-top object-fit-cover"
style="height:160px;object-fit:cover;">
<div class="card-body p-2 d-flex gap-1 flex-wrap">
{% if not photo.main %}
<form method="POST"
action="{{ path('admin_article_photos_set_main', {id: article.id, photoId: photo.id}) }}"
class="d-inline">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<button type="submit" class="btn btn-xs btn-outline-primary" title="Als Hauptfoto setzen">
<i class="fa fa-star"></i>
</button>
</form>
{% endif %}
<form method="POST"
action="{{ path('admin_article_photos_delete', {id: article.id, photoId: photo.id}) }}"
class="d-inline"
onsubmit="return confirm('Foto wirklich löschen?')">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<button type="submit" class="btn btn-xs btn-outline-danger" title="Löschen">
<i class="fa fa-trash"></i>
</button>
</form>
<span class="text-muted small ms-auto d-flex align-items-center">
#{{ loop.index }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% if photos|length > 1 %}
<div class="text-muted small">
<i class="fa fa-arrows-up-down-left-right me-1"></i>
Reihenfolge per Drag &amp; Drop ändern, dann speichern.
</div>
<button type="submit" class="btn btn-sm btn-outline-secondary mt-2" id="save-order-btn" style="display:none;">
<i class="fa fa-save me-1"></i>Reihenfolge speichern
</button>
{% endif %}
</form>
{% endif %}
</div>
</div>
</div>
{# ── Upload new photos ─────────────────────────────────────────────── #}
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-upload me-2"></i>Fotos hinzufügen</h5>
</div>
<div class="card-body">
<form method="POST"
action="{{ path('admin_article_photos_upload', {id: article.id}) }}"
enctype="multipart/form-data"
id="upload-form">
<input type="hidden" name="_token" value="{{ csrf_token('photos_' ~ article.id) }}">
<div id="drop-zone"
class="border-2 border-dashed rounded text-center p-4 text-muted mb-3"
style="cursor:pointer;border-style:dashed!important;transition:background .15s,border-color .15s;">
<i class="fa fa-cloud-upload-alt fa-2x d-block mb-2"></i>
<span>Fotos hier ablegen oder Schaltfläche nutzen</span>
<div id="preview-grid" class="d-flex flex-wrap gap-2 justify-content-center mt-3"></div>
</div>
<input type="file" id="photo-input" name="photos[]"
accept="image/jpeg,image/png,image/webp" multiple
style="position:absolute;opacity:0;width:0;height:0;">
<div class="d-flex gap-2">
<label for="photo-input" class="btn btn-outline-primary flex-fill mb-0" style="cursor:pointer;">
<i class="fa fa-folder-open me-2"></i>Dateien wählen
</label>
<button type="submit" class="btn btn-success flex-fill" id="upload-btn" disabled>
<i class="fa fa-upload me-2"></i>Hochladen
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<style>
.btn-xs { padding: .1rem .35rem; font-size: .75rem; }
.drop-over { background: #e8f0fe !important; border-color: #0d6efd !important; }
.photo-card.dragging { opacity: .4; }
.photo-card.drag-over > .card { outline: 2px dashed #0d6efd; }
</style>
<script>
(function () {
/* ── Upload drag & drop ───────────────────────────────────────── */
const dropZone = document.getElementById('drop-zone');
const photoInput = document.getElementById('photo-input');
const uploadBtn = document.getElementById('upload-btn');
const previewGrid = document.getElementById('preview-grid');
let selectedFiles = [];
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drop-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drop-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drop-over');
addFiles([...e.dataTransfer.files]);
});
dropZone.addEventListener('click', () => photoInput.click());
photoInput.addEventListener('change', () => {
addFiles([...photoInput.files]);
photoInput.value = '';
});
function addFiles(files) {
files.filter(f => f.type.startsWith('image/')).forEach(f => selectedFiles.push(f));
syncInput();
renderPreviews();
}
function syncInput() {
const dt = new DataTransfer();
selectedFiles.forEach(f => dt.items.add(f));
photoInput.files = dt.files;
uploadBtn.disabled = selectedFiles.length === 0;
}
function renderPreviews() {
previewGrid.innerHTML = '';
selectedFiles.forEach((f, i) => {
const wrap = document.createElement('div');
wrap.className = 'position-relative';
wrap.style.cssText = 'width:72px;height:72px;';
const img = document.createElement('img');
img.src = URL.createObjectURL(f);
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 position-absolute top-0 end-0 p-0 d-flex align-items-center justify-content-center';
del.style.cssText = 'width:18px;height:18px;font-size:.6rem;border-radius:50%;transform:translate(40%,-40%);';
del.textContent = '×';
del.onclick = () => { selectedFiles.splice(i, 1); syncInput(); renderPreviews(); };
wrap.appendChild(img);
wrap.appendChild(del);
previewGrid.appendChild(wrap);
});
}
/* ── Drag-to-reorder ──────────────────────────────────────────── */
const grid = document.getElementById('photo-grid');
const saveBtn = document.getElementById('save-order-btn');
if (grid && saveBtn) {
let dragged = null;
grid.querySelectorAll('.photo-card').forEach(card => {
card.setAttribute('draggable', 'true');
card.addEventListener('dragstart', () => {
dragged = card;
card.classList.add('dragging');
});
card.addEventListener('dragend', () => {
dragged = null;
card.classList.remove('dragging');
grid.querySelectorAll('.photo-card').forEach(c => c.classList.remove('drag-over'));
});
card.addEventListener('dragover', e => {
e.preventDefault();
if (dragged && dragged !== card) {
grid.querySelectorAll('.photo-card').forEach(c => c.classList.remove('drag-over'));
card.classList.add('drag-over');
}
});
card.addEventListener('drop', e => {
e.preventDefault();
card.classList.remove('drag-over');
if (dragged && dragged !== card) {
const allCards = [...grid.querySelectorAll('.photo-card')];
const fromIdx = allCards.indexOf(dragged);
const toIdx = allCards.indexOf(card);
if (fromIdx < toIdx) {
card.after(dragged);
} else {
card.before(dragged);
}
// update hidden order inputs
const newOrder = [...grid.querySelectorAll('.photo-card')];
newOrder.forEach((c, i) => {
c.querySelector('input[name="order[]"]').value = c.dataset.id;
});
saveBtn.style.display = 'inline-block';
}
});
});
}
})();
</script>
{% endblock %}