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;
|
||||
}
|
||||
|
||||
/* 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 --------------------------------- */
|
||||
[data-bs-toggle="collapse"] .sb-caret {
|
||||
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\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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,22 +15,33 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div class="col">
|
||||
<a href="{{ path('app_art_show', {id: art.id}) }}" class="card shadow-sm h-100 text-decoration-none text-body">
|
||||
<div class="art-thumb">
|
||||
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" loading="lazy">
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<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 class="col art-tile" data-art-reorder-target="item" data-art-id="{{ art.id }}">
|
||||
<div class="card shadow-sm h-100 position-relative">
|
||||
<button type="button" class="art-drag-handle" draggable="true"
|
||||
data-action="dragstart->art-reorder#dragStart dragend->art-reorder#dragEnd"
|
||||
title="Drag to reorder" aria-label="Drag to reorder {{ art.name }}">✥</button>
|
||||
<a href="{{ path('app_art_show', {id: art.id}) }}" class="text-decoration-none text-body">
|
||||
<div class="art-thumb">
|
||||
<img src="{{ path('app_art_file', {id: art.id}) }}" alt="{{ art.name }}" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="card-body py-2">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="art-reorder-status" data-art-reorder-target="status" aria-live="polite"></div>
|
||||
{% endif %}
|
||||
{% 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