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:
Simon Kuehn 2026-05-18 09:10:59 +00:00
parent 0c278aefbf
commit 693e458e07
8 changed files with 293 additions and 0 deletions

View file

@ -82,6 +82,10 @@ services:
arguments: arguments:
$model: '%env(AI_TEXT_MODEL)%' $model: '%env(AI_TEXT_MODEL)%'
App\Infrastructure\Http\Controller\Admin\PhotoServeController:
arguments:
$uploadsDir: '%kernel.project_dir%/var/uploads'
App\Infrastructure\Search\WebSearchInterface: App\Infrastructure\Search\WebSearchInterface:
alias: App\Infrastructure\Search\TavilyWebSearch alias: App\Infrastructure\Search\TavilyWebSearch

View file

@ -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 public function delete(Uuid $photoId): void
{ {
$photo = $this->photoRepository->findById($photoId) $photo = $this->photoRepository->findById($photoId)

View file

@ -139,6 +139,10 @@ final class ArticleCrudController extends AbstractCrudController
->setEntryIsComplex(false) ->setEntryIsComplex(false)
->allowAdd(false) ->allowAdd(false)
->allowDelete(false); ->allowDelete(false);
yield TextField::new('photos', new TranslatableMessage('field.photos', [], 'admin'))
->setTemplatePath('admin/field/photos.html.twig')
->onlyOnDetail();
} }
#[AdminRoute('/rerun-ai', name: 'rerunAi')] #[AdminRoute('/rerun-ai', name: 'rerunAi')]

View file

@ -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);
}
}

View file

@ -62,6 +62,26 @@ final class PhotoController extends AbstractController
return $this->json(null, Response::HTTP_NO_CONTENT); 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'])] #[Route('/{photoId}', name: 'delete', methods: ['DELETE'])]
public function delete(string $articleId, string $photoId): JsonResponse public function delete(string $articleId, string $photoId): JsonResponse
{ {

View 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>

View file

@ -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: 'Prompt-Text'
field.prompt_body_preview: Vorschau field.prompt_body_preview: Vorschau
field.last_updated: 'Zuletzt geändert' field.last_updated: 'Zuletzt geändert'
field.photos: Fotos
field.locale: Sprache field.locale: Sprache
field.domain: Bereich field.domain: Bereich
field.translation_key: Schlüssel field.translation_key: Schlüssel

View file

@ -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: 'Prompt Body'
field.prompt_body_preview: 'Body Preview' field.prompt_body_preview: 'Body Preview'
field.last_updated: 'Last Updated' field.last_updated: 'Last Updated'
field.photos: Photos
field.locale: Locale field.locale: Locale
field.domain: Domain field.domain: Domain
field.translation_key: Key field.translation_key: Key