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;
|
||||
|
||||
Reference in New Issue
Block a user