feat: photo gallery on article detail with upload, sort and set-main
- Add secured GET /admin/photos/{filename} to serve files from var/uploads/
- Add POST /api/articles/{id}/photos/sort for drag-and-drop reordering
- Add PhotoService::reorder() to persist new sort positions
- Add photo gallery field (onlyOnDetail) using custom Twig template:
- Drag-and-drop reorder via SortableJS CDN
- Click or drop to upload new photos
- Set main (★) and delete per photo
- Main photo highlighted with blue border + badge
- Add field.photos translation key (EN/DE)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0c278aefbf
commit
693e458e07
8 changed files with 293 additions and 0 deletions
|
|
@ -82,6 +82,10 @@ services:
|
|||
arguments:
|
||||
$model: '%env(AI_TEXT_MODEL)%'
|
||||
|
||||
App\Infrastructure\Http\Controller\Admin\PhotoServeController:
|
||||
arguments:
|
||||
$uploadsDir: '%kernel.project_dir%/var/uploads'
|
||||
|
||||
App\Infrastructure\Search\WebSearchInterface:
|
||||
alias: App\Infrastructure\Search\TavilyWebSearch
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,25 @@ final class PhotoService
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $orderedPhotoIds UUIDs in the desired display order
|
||||
*/
|
||||
public function reorder(Uuid $articleId, array $orderedPhotoIds): void
|
||||
{
|
||||
$photos = $this->photoRepository->findByArticle($articleId);
|
||||
$indexed = [];
|
||||
foreach ($photos as $photo) {
|
||||
$indexed[$photo->getId()->toRfc4122()] = $photo;
|
||||
}
|
||||
|
||||
foreach ($orderedPhotoIds as $position => $id) {
|
||||
if (isset($indexed[$id])) {
|
||||
$indexed[$id]->setSortOrder($position);
|
||||
$this->photoRepository->save($indexed[$id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Uuid $photoId): void
|
||||
{
|
||||
$photo = $this->photoRepository->findById($photoId)
|
||||
|
|
|
|||
|
|
@ -139,6 +139,10 @@ final class ArticleCrudController extends AbstractCrudController
|
|||
->setEntryIsComplex(false)
|
||||
->allowAdd(false)
|
||||
->allowDelete(false);
|
||||
|
||||
yield TextField::new('photos', new TranslatableMessage('field.photos', [], 'admin'))
|
||||
->setTemplatePath('admin/field/photos.html.twig')
|
||||
->onlyOnDetail();
|
||||
}
|
||||
|
||||
#[AdminRoute('/rerun-ai', name: 'rerunAi')]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Infrastructure\Http\Controller\Admin;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_USER')]
|
||||
final class PhotoServeController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly string $uploadsDir)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/photos/{filename}', name: 'admin_photo_serve', requirements: ['filename' => '[a-zA-Z0-9\-\.]+'], methods: ['GET'])]
|
||||
public function serve(string $filename): Response
|
||||
{
|
||||
$path = rtrim($this->uploadsDir, '/').'/'.$filename;
|
||||
|
||||
if (!file_exists($path) || !is_file($path)) {
|
||||
throw $this->createNotFoundException('Photo not found.');
|
||||
}
|
||||
|
||||
return new BinaryFileResponse($path);
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,26 @@ final class PhotoController extends AbstractController
|
|||
return $this->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
#[Route('/sort', name: 'sort', methods: ['POST'])]
|
||||
public function sort(string $articleId, Request $request): JsonResponse
|
||||
{
|
||||
/** @var array{order?: list<string>} $body */
|
||||
$body = $request->toArray();
|
||||
$order = $body['order'] ?? [];
|
||||
|
||||
if ([] === $order) {
|
||||
return $this->json(['error' => 'order array required'], Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->service->reorder(Uuid::fromString($articleId), $order);
|
||||
} catch (\DomainException $e) {
|
||||
return $this->json(['error' => $e->getMessage()], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return $this->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
|
||||
#[Route('/{photoId}', name: 'delete', methods: ['DELETE'])]
|
||||
public function delete(string $articleId, string $photoId): JsonResponse
|
||||
{
|
||||
|
|
|
|||
213
templates/admin/field/photos.html.twig
Normal file
213
templates/admin/field/photos.html.twig
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
{# Article photo gallery — shown on detail page #}
|
||||
{% set article = ea.entity.instance %}
|
||||
{% set articleId = article.id.toRfc4122() %}
|
||||
{% set photos = article.photos|sort((a, b) => a.sortOrder <=> b.sortOrder) %}
|
||||
|
||||
<style>
|
||||
.photo-gallery { margin-top: .5rem; }
|
||||
.photo-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
min-height: 80px;
|
||||
}
|
||||
.photo-card {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f8f9fa;
|
||||
cursor: grab;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.photo-card:active { cursor: grabbing; }
|
||||
.photo-card.is-main { border-color: #0d6efd; }
|
||||
.photo-card.sortable-ghost { opacity: .4; }
|
||||
.photo-card img {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.photo-card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
background: rgba(0,0,0,.04);
|
||||
}
|
||||
.photo-main-badge {
|
||||
font-size: .65rem;
|
||||
font-weight: 700;
|
||||
color: #0d6efd;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.photo-card-actions .btn { padding: 2px 6px; font-size: .75rem; }
|
||||
.upload-zone {
|
||||
border: 2px dashed #adb5bd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
transition: border-color .2s, background .2s;
|
||||
}
|
||||
.upload-zone.drag-over {
|
||||
border-color: #0d6efd;
|
||||
background: #e8f0fe;
|
||||
}
|
||||
.upload-zone input[type=file] { display: none; }
|
||||
</style>
|
||||
|
||||
<div class="photo-gallery" id="photo-gallery-{{ articleId }}">
|
||||
{# Upload zone #}
|
||||
<div class="upload-zone" id="upload-zone-{{ articleId }}">
|
||||
<input type="file" id="photo-file-input-{{ articleId }}"
|
||||
accept="image/jpeg,image/png,image/webp" multiple>
|
||||
<i class="fa fa-cloud-upload-alt fa-lg mb-2 d-block"></i>
|
||||
<span>Fotos hier ablegen oder <strong>klicken zum Hochladen</strong></span>
|
||||
<div id="upload-status-{{ articleId }}" class="mt-2 small"></div>
|
||||
</div>
|
||||
|
||||
{# Photo grid #}
|
||||
<div class="photo-grid" id="photo-grid-{{ articleId }}">
|
||||
{% for photo in photos %}
|
||||
<div class="photo-card {% if photo.isMain %}is-main{% endif %}"
|
||||
data-photo-id="{{ photo.id.toRfc4122() }}">
|
||||
<img src="{{ path('admin_photo_serve', {filename: photo.filename}) }}"
|
||||
alt="Photo">
|
||||
<div class="photo-card-actions">
|
||||
<span class="photo-main-badge">{% if photo.isMain %}⭐ Main{% endif %}</span>
|
||||
<div class="d-flex gap-1">
|
||||
{% if not photo.isMain %}
|
||||
<button class="btn btn-sm btn-outline-primary photo-btn-main"
|
||||
title="Als Hauptbild setzen">★</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-outline-danger photo-btn-delete"
|
||||
title="Löschen">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const articleId = {{ articleId|json_encode|raw }};
|
||||
const grid = document.getElementById('photo-grid-' + articleId);
|
||||
const zone = document.getElementById('upload-zone-' + articleId);
|
||||
const fileInput = document.getElementById('photo-file-input-' + articleId);
|
||||
const status = document.getElementById('upload-status-' + articleId);
|
||||
|
||||
/* ---------- drag-and-drop reorder ---------- */
|
||||
Sortable.create(grid, {
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
onEnd() {
|
||||
const order = [...grid.querySelectorAll('.photo-card')]
|
||||
.map(el => el.dataset.photoId);
|
||||
fetch('/api/articles/' + articleId + '/photos/sort', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/* ---------- upload ---------- */
|
||||
zone.addEventListener('click', () => fileInput.click());
|
||||
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag-over'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('drag-over');
|
||||
uploadFiles(e.dataTransfer.files);
|
||||
});
|
||||
fileInput.addEventListener('change', () => uploadFiles(fileInput.files));
|
||||
|
||||
async function uploadFiles(files) {
|
||||
for (const file of files) {
|
||||
status.textContent = 'Lade hoch: ' + file.name + '…';
|
||||
const fd = new FormData();
|
||||
fd.append('photo', file);
|
||||
const res = await fetch('/api/articles/' + articleId + '/photos', {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
appendPhotoCard(data.id, '/admin/photos/' + encodeURIComponent(file.name.replace(/^.*[\\/]/, '')), data.isMain, file);
|
||||
status.textContent = '';
|
||||
} else {
|
||||
status.textContent = 'Fehler beim Hochladen von ' + file.name;
|
||||
}
|
||||
}
|
||||
fileInput.value = '';
|
||||
}
|
||||
|
||||
function appendPhotoCard(photoId, imgSrc, isMain, file) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'photo-card' + (isMain ? ' is-main' : '');
|
||||
card.dataset.photoId = photoId;
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.alt = 'Photo';
|
||||
card.appendChild(img);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'photo-card-actions';
|
||||
actions.innerHTML = '<span class="photo-main-badge">' + (isMain ? '⭐ Main' : '') + '</span>'
|
||||
+ '<div class="d-flex gap-1">'
|
||||
+ (isMain ? '' : '<button class="btn btn-sm btn-outline-primary photo-btn-main" title="Als Hauptbild setzen">★</button>')
|
||||
+ '<button class="btn btn-sm btn-outline-danger photo-btn-delete" title="Löschen">×</button>'
|
||||
+ '</div>';
|
||||
card.appendChild(actions);
|
||||
grid.appendChild(card);
|
||||
wireCard(card);
|
||||
}
|
||||
|
||||
/* ---------- set main / delete ---------- */
|
||||
function wireCard(card) {
|
||||
const btnMain = card.querySelector('.photo-btn-main');
|
||||
const btnDel = card.querySelector('.photo-btn-delete');
|
||||
|
||||
btnMain?.addEventListener('click', async () => {
|
||||
await fetch('/api/articles/' + articleId + '/photos/' + card.dataset.photoId + '/main', {
|
||||
method: 'PATCH',
|
||||
});
|
||||
// update badges
|
||||
grid.querySelectorAll('.photo-card').forEach(c => {
|
||||
c.classList.remove('is-main');
|
||||
c.querySelector('.photo-main-badge').textContent = '';
|
||||
const bm = c.querySelector('.photo-btn-main');
|
||||
if (!bm) {
|
||||
const acts = c.querySelector('.photo-card-actions .d-flex');
|
||||
acts.insertAdjacentHTML('afterbegin',
|
||||
'<button class="btn btn-sm btn-outline-primary photo-btn-main" title="Als Hauptbild setzen">★</button>');
|
||||
wireCard(c);
|
||||
}
|
||||
});
|
||||
card.classList.add('is-main');
|
||||
card.querySelector('.photo-main-badge').textContent = '⭐ Main';
|
||||
btnMain.remove();
|
||||
});
|
||||
|
||||
btnDel?.addEventListener('click', async () => {
|
||||
if (!confirm('Foto löschen?')) return;
|
||||
const res = await fetch('/api/articles/' + articleId + '/photos/' + card.dataset.photoId, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (res.ok) card.remove();
|
||||
});
|
||||
}
|
||||
|
||||
grid.querySelectorAll('.photo-card').forEach(wireCard);
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -71,6 +71,7 @@ field.prompt_key_help: 'Eindeutiger Bezeichner für den Prompt (z. B. <code>spec
|
|||
field.prompt_body: 'Prompt-Text'
|
||||
field.prompt_body_preview: Vorschau
|
||||
field.last_updated: 'Zuletzt geändert'
|
||||
field.photos: Fotos
|
||||
field.locale: Sprache
|
||||
field.domain: Bereich
|
||||
field.translation_key: Schlüssel
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ field.prompt_key_help: 'Slug identifying the prompt (e.g. <code>specs_research</
|
|||
field.prompt_body: 'Prompt Body'
|
||||
field.prompt_body_preview: 'Body Preview'
|
||||
field.last_updated: 'Last Updated'
|
||||
field.photos: Photos
|
||||
field.locale: Locale
|
||||
field.domain: Domain
|
||||
field.translation_key: Key
|
||||
|
|
|
|||
Loading…
Reference in a new issue