diff --git a/assets/controllers/art_reorder_controller.js b/assets/controllers/art_reorder_controller.js new file mode 100644 index 0000000..27fd260 --- /dev/null +++ b/assets/controllers/art_reorder_controller.js @@ -0,0 +1,94 @@ +import { Controller } from '@hotwired/stimulus'; + +/* + * Drag-to-reorder for the art gallery. + * + *
+ *
+ * + * ... + *
+ * ... + *
+ *
+ * + * 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); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index ca04aab..f0fa77b 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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; diff --git a/migrations/Version20260915151210.php b/migrations/Version20260915151210.php new file mode 100644 index 0000000..f9defd9 --- /dev/null +++ b/migrations/Version20260915151210.php @@ -0,0 +1,44 @@ +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'); + } +} diff --git a/src/Controller/ArtController.php b/src/Controller/ArtController.php index 55ce8bb..dd7de7a 100644 --- a/src/Controller/ArtController.php +++ b/src/Controller/ArtController.php @@ -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(); diff --git a/src/Entity/Artwork.php b/src/Entity/Artwork.php index f547265..2eca011 100644 --- a/src/Entity/Artwork.php +++ b/src/Entity/Artwork.php @@ -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; + } } diff --git a/src/Repository/ArtworkRepository.php b/src/Repository/ArtworkRepository.php index 06c1095..46c417f 100644 --- a/src/Repository/ArtworkRepository.php +++ b/src/Repository/ArtworkRepository.php @@ -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; + } } diff --git a/templates/art/index.html.twig b/templates/art/index.html.twig index f20de51..b8bc97a 100644 --- a/templates/art/index.html.twig +++ b/templates/art/index.html.twig @@ -15,22 +15,33 @@ {% else %} -
+

Drag the ✥ handle to reorder.

+
{% for art in artworks %} -
- -
- {{ art.name }} -
-
-
{{ art.name }}
-
- {{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB +
+ - +
+
{{ art.name }}
+
+ {{ art.uploadedAt|date('Y-m-d') }} · {{ (art.size / 1024)|round }} KB +
+
+ +
{% endfor %}
+
{% endif %} {% endblock %} diff --git a/tests/Entity/ArtworkTest.php b/tests/Entity/ArtworkTest.php new file mode 100644 index 0000000..e10093a --- /dev/null +++ b/tests/Entity/ArtworkTest.php @@ -0,0 +1,20 @@ +getPosition()); + + $art->setPosition(3); + + self::assertSame(3, $art->getPosition()); + } +}