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
@@ -0,0 +1,94 @@
import { Controller } from '@hotwired/stimulus';
/*
* Drag-to-reorder for the art gallery.
*
* <div data-controller="art-reorder"
* data-art-reorder-url-value="/art/reorder"
* data-art-reorder-token-value="...">
* <div data-art-reorder-target="item" data-art-id="5">
* <button draggable="true"
* data-action="dragstart->art-reorder#dragStart dragend->art-reorder#dragEnd">⠿</button>
* ...
* </div>
* ...
* </div>
* <div data-art-reorder-target="status"></div>
*
* The dragged tile is moved live in the DOM as the pointer crosses other
* tiles (dragover, bound on the grid itself via data-action); the new order
* is only sent to the server once, on dragend.
*/
export default class extends Controller {
static targets = ['item', 'status'];
static values = { url: String, token: String };
dragStart(event) {
const tile = event.currentTarget.closest('[data-art-reorder-target="item"]');
if (!tile) return;
this.dragEl = tile;
this.startOrder = this.currentOrder();
tile.classList.add('is-dragging');
event.dataTransfer.effectAllowed = 'move';
// Firefox requires data to be set for the drag to start at all.
event.dataTransfer.setData('text/plain', tile.dataset.artId);
}
dragOver(event) {
if (!this.dragEl) return;
event.preventDefault();
const over = event.target.closest('[data-art-reorder-target="item"]');
if (!over || over === this.dragEl) return;
const rect = over.getBoundingClientRect();
const before = event.clientX - rect.left < rect.width / 2;
over.parentNode.insertBefore(this.dragEl, before ? over : over.nextSibling);
}
drop(event) {
event.preventDefault();
}
dragEnd() {
if (!this.dragEl) return;
this.dragEl.classList.remove('is-dragging');
const order = this.currentOrder();
const changed = order.join(',') !== (this.startOrder || []).join(',');
this.dragEl = null;
if (changed) this.persist(order);
}
currentOrder() {
return this.itemTargets.map((el) => el.dataset.artId);
}
async persist(order) {
this.showStatus('Saving order…');
try {
const res = await fetch(this.urlValue, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ _token: this.tokenValue, order }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.showStatus('Order saved');
} catch (e) {
console.error('art reorder save failed', e);
this.showStatus('Could not save the new order — reload and try again', true);
}
}
showStatus(text, isError = false) {
if (!this.hasStatusTarget) return;
clearTimeout(this.statusTimer);
this.statusTarget.textContent = text;
this.statusTarget.classList.toggle('is-error', isError);
this.statusTarget.classList.add('is-visible');
this.statusTimer = setTimeout(() => this.statusTarget.classList.remove('is-visible'), isError ? 4000 : 1800);
}
}
+41
View File
@@ -113,6 +113,47 @@ table.dataTable {
height: auto; height: auto;
} }
/* Drag-to-reorder handle, top-left of each tile */
.art-drag-handle {
position: absolute;
top: 0.35rem;
left: 0.35rem;
z-index: 2;
width: 1.9rem;
height: 1.9rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--bs-border-color);
border-radius: 0.35rem;
background: var(--bs-body-bg);
color: var(--bs-secondary-color);
opacity: 0.8;
cursor: grab;
line-height: 1;
}
.art-drag-handle:hover { opacity: 1; color: var(--bs-body-color); }
.art-drag-handle:active { cursor: grabbing; }
.art-tile.is-dragging { opacity: 0.4; }
.art-tile.is-drag-over .card { outline: 2px dashed var(--bs-primary); outline-offset: 2px; }
.art-reorder-status {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 1080;
padding: 0.4rem 0.85rem;
border-radius: 2rem;
background: var(--bs-body-color);
color: var(--bs-body-bg);
font-size: 0.85rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.art-reorder-status.is-visible { opacity: 0.92; }
.art-reorder-status.is-error { background: var(--bs-danger); color: #fff; }
/* --- Tracker: unfoldable stat blocks --------------------------------- */ /* --- Tracker: unfoldable stat blocks --------------------------------- */
[data-bs-toggle="collapse"] .sb-caret { [data-bs-toggle="collapse"] .sb-caret {
display: inline-block; display: inline-block;
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260915151210 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add artwork.position for the drag-to-reorder gallery, backfilled by upload date';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE artwork ADD position INT NOT NULL DEFAULT 0');
// Backfill existing rows so the gallery keeps its current (upload-date) order
// until someone drags a piece; new uploads get their position in PHP.
$this->addSql(<<<'SQL'
UPDATE artwork a
JOIN (
SELECT id, ROW_NUMBER() OVER (ORDER BY uploaded_at ASC, id ASC) AS rn
FROM artwork
) ranked ON ranked.id = a.id
SET a.position = ranked.rn
SQL);
// Drop the bootstrap default now that every row has a real position;
// the mapping has no default, so schema:validate expects none either.
$this->addSql('ALTER TABLE artwork CHANGE position position INT NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE artwork DROP position');
}
}
+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\Exception\FileException;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\HeaderUtils; use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -30,12 +31,43 @@ class ArtController extends AbstractController
public function index(ArtworkRepository $repo): Response public function index(ArtworkRepository $repo): Response
{ {
return $this->render('art/index.html.twig', [ 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'])] #[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(); $artwork = new Artwork();
$form = $this->createForm(ArtworkType::class, $artwork); $form = $this->createForm(ArtworkType::class, $artwork);
@@ -62,7 +94,8 @@ class ArtController extends AbstractController
->setFilename($stored) ->setFilename($stored)
->setOriginalName($file->getClientOriginalName()) ->setOriginalName($file->getClientOriginalName())
->setMimeType($file->getClientMimeType()) ->setMimeType($file->getClientMimeType())
->setSize((int) (@filesize($this->artDir.'/'.$stored) ?: 0)); ->setSize((int) (@filesize($this->artDir.'/'.$stored) ?: 0))
->setPosition($repo->nextPosition());
$em->persist($artwork); $em->persist($artwork);
$em->flush(); $em->flush();
+16
View File
@@ -36,6 +36,10 @@ class Artwork
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
private \DateTimeImmutable $uploadedAt; 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() public function __construct()
{ {
$this->uploadedAt = new \DateTimeImmutable(); $this->uploadedAt = new \DateTimeImmutable();
@@ -110,4 +114,16 @@ class Artwork
{ {
return $this->uploadedAt; 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); 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;
}
} }
+23 -12
View File
@@ -15,22 +15,33 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3"> <p class="text-body-secondary small mb-2">Drag the <span class="art-drag-handle d-inline-flex">&#10021;</span> handle to reorder.</p>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3 art-grid"
data-controller="art-reorder"
data-art-reorder-url-value="{{ path('app_art_reorder') }}"
data-art-reorder-token-value="{{ csrf_token('reorder-art') }}"
data-action="dragover->art-reorder#dragOver drop->art-reorder#drop">
{% for art in artworks %} {% for art in artworks %}
<div class="col"> <div class="col art-tile" data-art-reorder-target="item" data-art-id="{{ art.id }}">
<a href="{{ path('app_art_show', {id: art.id}) }}" class="card shadow-sm h-100 text-decoration-none text-body"> <div class="card shadow-sm h-100 position-relative">
<div class="art-thumb"> <button type="button" class="art-drag-handle" draggable="true"
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" loading="lazy"> data-action="dragstart->art-reorder#dragStart dragend->art-reorder#dragEnd"
</div> title="Drag to reorder" aria-label="Drag to reorder {{ art.name }}">&#10021;</button>
<div class="card-body py-2"> <a href="{{ path('app_art_show', {id: art.id}) }}" class="text-decoration-none text-body">
<div class="fw-semibold text-truncate" title="{{ art.name }}">{{ art.name }}</div> <div class="art-thumb">
<div class="text-body-secondary small"> <img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" loading="lazy">
{{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB
</div> </div>
</div> <div class="card-body py-2">
</a> <div class="fw-semibold text-truncate" title="{{ art.name }}">{{ art.name }}</div>
<div class="text-body-secondary small">
{{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB
</div>
</div>
</a>
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="art-reorder-status" data-art-reorder-target="status" aria-live="polite"></div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Artwork;
use PHPUnit\Framework\TestCase;
class ArtworkTest extends TestCase
{
public function testPositionDefaultsToZeroAndIsSettable(): void
{
$art = new Artwork();
self::assertSame(0, $art->getPosition());
$art->setPosition(3);
self::assertSame(3, $art->getPosition());
}
}