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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user