Art: drag pieces into a new order

Each gallery tile gets a small drag handle (top-left, ✱). Grab it and
drop it anywhere among the others - the tile moves live as you drag
over neighbours, then the new order is saved automatically on drop.

- Artwork gets a `position` column; the gallery lists by position (was
  upload date). New uploads land after everything else.
- New POST /art/reorder endpoint takes an ordered list of ids (one
  shared CSRF token, "reorder-art") and rewrites positions 1..n.
- Migration backfills position from the existing upload-date order via
  ROW_NUMBER(), then drops the bootstrap default so it matches the
  Doctrine mapping.
- New Stimulus controller (art-reorder): native HTML5 drag/drop, no
  library. dragover live-reorders the DOM by comparing cursor x to the
  hovered tile's midpoint; dragend posts the resulting id order and
  shows a small "Saving order… / Order saved" status pill.

Verified: schema validate clean: a throwaway script exercised
persist+reload of a reordered set against the real DB, and a live
browser check dispatched real DragEvents through the actual Stimulus
controller and confirmed the exact POST payload it sent. phpunit 9/9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Frank
2026-09-15 17:18:02 +02:00
co-authored by Claude Sonnet 5
parent bc0433e356
commit 8b8f67c1d0
8 changed files with 291 additions and 15 deletions
+36 -3
View File
@@ -12,6 +12,7 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -30,12 +31,43 @@ class ArtController extends AbstractController
public function index(ArtworkRepository $repo): Response
{
return $this->render('art/index.html.twig', [
'artworks' => $repo->findBy([], ['uploadedAt' => 'DESC']),
'artworks' => $repo->findAllOrdered(),
]);
}
/**
* Persists a drag-reordered gallery: body is {_token, order: [id, id, ...]}.
*/
#[Route('/art/reorder', name: 'app_art_reorder', methods: ['POST'])]
public function reorder(Request $request, ArtworkRepository $repo, EntityManagerInterface $em): JsonResponse
{
$payload = json_decode($request->getContent(), true) ?? [];
if (!$this->isCsrfTokenValid('reorder-art', (string) ($payload['_token'] ?? ''))) {
return $this->json(['error' => 'Invalid CSRF token.'], Response::HTTP_BAD_REQUEST);
}
$order = array_map('intval', \is_array($payload['order'] ?? null) ? $payload['order'] : []);
if (!$order) {
return $this->json(['error' => 'No order given.'], Response::HTTP_BAD_REQUEST);
}
$byId = [];
foreach ($repo->findBy(['id' => $order]) as $artwork) {
$byId[$artwork->getId()] = $artwork;
}
$position = 1;
foreach ($order as $id) {
$byId[$id]?->setPosition($position++);
}
$em->flush();
return $this->json(['ok' => true]);
}
#[Route('/art/upload', name: 'app_art_upload', methods: ['GET', 'POST'])]
public function upload(Request $request, EntityManagerInterface $em): Response
public function upload(Request $request, EntityManagerInterface $em, ArtworkRepository $repo): Response
{
$artwork = new Artwork();
$form = $this->createForm(ArtworkType::class, $artwork);
@@ -62,7 +94,8 @@ class ArtController extends AbstractController
->setFilename($stored)
->setOriginalName($file->getClientOriginalName())
->setMimeType($file->getClientMimeType())
->setSize((int) (@filesize($this->artDir.'/'.$stored) ?: 0));
->setSize((int) (@filesize($this->artDir.'/'.$stored) ?: 0))
->setPosition($repo->nextPosition());
$em->persist($artwork);
$em->flush();
+16
View File
@@ -36,6 +36,10 @@ class Artwork
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $uploadedAt;
/** Manual sort order for the gallery; smaller sorts first. Set on upload, changed by drag-reorder. */
#[ORM\Column(type: Types::INTEGER)]
private int $position = 0;
public function __construct()
{
$this->uploadedAt = new \DateTimeImmutable();
@@ -110,4 +114,16 @@ class Artwork
{
return $this->uploadedAt;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}
+17
View File
@@ -15,4 +15,21 @@ class ArtworkRepository extends ServiceEntityRepository
{
parent::__construct($registry, Artwork::class);
}
/** The gallery order: manual position, oldest upload first as a tiebreak. */
public function findAllOrdered(): array
{
return $this->findBy([], ['position' => 'ASC', 'uploadedAt' => 'ASC']);
}
/** Where a newly uploaded piece belongs: after everything already in the gallery. */
public function nextPosition(): int
{
$max = $this->createQueryBuilder('a')
->select('MAX(a.position)')
->getQuery()
->getSingleScalarResult();
return ((int) $max) + 1;
}
}