Some checks are pending
CI / test (push) Waiting to run
- 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>
261 lines
13 KiB
Twig
261 lines
13 KiB
Twig
{% 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 & 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 %}
|