Files
dmtools/assets/controllers/art_reorder_controller.js
FrankandClaude Sonnet 5 8b8f67c1d0 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>
2026-09-15 17:18:02 +02:00

95 lines
3.2 KiB
JavaScript

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);
}
}