SuperSeller3000/templates/admin/photo_management.html.twig
Simon Kuehn fc18958e0e
Some checks are pending
CI / test (push) Waiting to run
feat: article photo management + eBay image URLs
- Public photo endpoint at /photos/{filename} (no auth, UUID-based filenames)
- Admin photo management page per article: upload multiple, delete, set main, drag-reorder
- "Fotos verwalten" action button on article index + detail pages
- EbayAdapter.publishListing() now includes imageUrls (main photo first, max 24)
- APP_PUBLIC_URL env var for absolute URL generation in Messenger workers

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

261 lines
13 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">
{# ── 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 %}