262 lines
13 KiB
Twig
262 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 %}
|