diff --git a/electron/package.json b/electron/package.json index 151ffd2..908a925 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "hyprcrd", - "version": "2.0.13", + "version": "2.0.14", "description": "hyprcrd — self-hosted card manager", "main": "main.js", "scripts": { diff --git a/public/css/style.css b/public/css/style.css index 01f5040..e5f6ed4 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -524,6 +524,36 @@ body.rounded-cards .card-done-badge i { position: static; bottom: auto; right: a animation: card-remove 0.25s ease-in forwards; pointer-events: none; } +/* Move-in-flight: card ghosted with a spinner overlay while the + * PATCH + reloadAll round-trip completes. The pseudo-elements paint + * a dim scrim and a centred spinning ring so the origin bucket + * clearly shows the card is "leaving". Removed by the next + * renderBoard() since the DOM is rebuilt from scratch. */ +.board-card.card-moving { + opacity: 0.5; + pointer-events: none; + position: relative; +} +.board-card.card-moving::before { + content: ""; + position: absolute; inset: 0; + background: rgba(0, 0, 0, 0.25); + z-index: 3; + pointer-events: none; +} +.board-card.card-moving::after { + content: ""; + position: absolute; + top: 50%; left: 50%; + width: 22px; height: 22px; + margin: -11px 0 0 -11px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--t-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + z-index: 4; + pointer-events: none; +} .box-select { position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08); pointer-events: none; z-index: 10; diff --git a/public/js/modules/board.js b/public/js/modules/board.js index cc380e6..8ad4a83 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -50,6 +50,20 @@ export function animateCardRemoval(ids) { return new Promise(r => 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(); })); }