From 4baeee159e53c50daee7076dce8abdb25998354f Mon Sep 17 00:00:00 2001
From: th
Date: Sun, 19 Apr 2026 14:44:03 +0200
Subject: [PATCH] =?UTF-8?q?v2.0.14=20=E2=80=94=20ghost=20+=20spinner=20on?=
=?UTF-8?q?=20card=20move?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Moving a card to another bucket took a visible round-trip (PATCH +
reloadAll) during which the card sat unchanged in the source bucket,
leaving the user wondering if the drop registered. Now every
bucket-move path marks the affected cards `.card-moving` as soon as
the action fires: 50% opacity, dark scrim and a centred accent-ring
spinner. The next renderBoard() rebuilds the DOM so the class comes
off automatically — no explicit cleanup needed.
Call sites:
- drag-drop onto a bucket tile (single + multi-select)
- drag onto an existing bucket to nest it (auto-super-bucket create)
- context-menu → Bucket →
Co-Authored-By: Claude Opus 4.7 (1M context)
---
config.php | 2 +-
electron/package.json | 2 +-
public/css/style.css | 30 ++++++++++++++++++++++++++++++
public/js/modules/board.js | 14 ++++++++++++++
public/js/modules/buckets.js | 2 ++
public/js/modules/context-menu.js | 8 +++++++-
6 files changed, 55 insertions(+), 3 deletions(-)
diff --git a/config.php b/config.php
index 3dbe3b6..f141c59 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
setTimeout(r, 260));
}
+/** Ghost-and-spinner overlay for cards that are being moved between
+ * buckets. The PATCH + reloadAll round-trip used to leave the original
+ * tile sitting stale in the current bucket for a couple hundred ms;
+ * marking them gives immediate feedback. The class comes off naturally
+ * on the next renderBoard() (the DOM is rebuilt from fresh state). */
+export function markCardsMoving(ids) {
+ if (!ids || !ids.length) return;
+ for (const id of ids) {
+ const el = board.querySelector(`[data-id="${id}"]`);
+ if (el) el.classList.add('card-moving');
+ }
+}
+window._markCardsMoving = markCardsMoving;
+
/** Add a pulse class to matching board cards and strip it after the
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js
index 4026c6b..d5e662b 100644
--- a/public/js/modules/buckets.js
+++ b/public/js/modules/buckets.js
@@ -627,6 +627,7 @@ function renderBar() {
const targetBid = isSuperTile ? (t.dataset.superShown || bid) : bid;
const bucketId = targetBid === 'none' ? null : (['all','current','journal'].includes(targetBid) ? undefined : targetBid);
if (bucketId === undefined) return;
+ window._markCardsMoving?.(ids);
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } })));
window._clearSelection?.();
await window._reloadAll();
@@ -672,6 +673,7 @@ function renderBar() {
const selected = window._getSelectedIds?.() || [];
const ids = selected.includes(String(cardId)) ? selected : [String(cardId)];
const card = state.cards.find(c => String(c.id) === String(cardId));
+ window._markCardsMoving?.(ids);
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
if (!created?.id) return;
await Promise.all(ids.map(id => api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } })));
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index 0663b84..167de9f 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -416,7 +416,13 @@ export function showContextMenu(x, y, card) {
}));
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
- buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await window._reloadAll(); }));
+ buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => {
+ e.stopPropagation(); hideContextMenu();
+ pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id });
+ window._markCardsMoving?.([card.id]);
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } });
+ await window._reloadAll();
+ }));
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
}
|