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