diff --git a/server/app/templates/base.html b/server/app/templates/base.html
index b47369e..24f3638 100644
--- a/server/app/templates/base.html
+++ b/server/app/templates/base.html
@@ -261,8 +261,20 @@
}
.photo-card:hover { box-shadow: var(--shadow-hover); transform: translateY(-1px); }
.photo-card:active { cursor: grabbing; }
- .photo-card.dragging { opacity: 0.35; }
- .photo-card.drag-armed { box-shadow: 0 0 0 3px var(--focus-ring) inset; }
+ .photo-card.drag-armed {
+ box-shadow: 0 0 0 3px var(--focus-ring) inset;
+ transform: scale(0.97);
+ }
+ /* No transform transition here -- the JS drives transform on every
+ pointermove to track the finger 1:1, and the .15s base transition
+ would otherwise make it visibly lag behind a fast swipe. */
+ .photo-card.dragging {
+ z-index: 20;
+ box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
+ transition: box-shadow .15s ease;
+ pointer-events: none; /* so elementFromPoint hits the card underneath, not this one */
+ cursor: grabbing;
+ }
.photo-card.drag-over { outline: 3px solid var(--accent); outline-offset: -3px; }
.photo-card img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; }
.photo-card .badge { position: absolute; top: 6px; left: 6px; background: var(--overlay); color: white; font-size: 10px; padding: 2px 6px; border-radius: 4px; }
diff --git a/server/app/templates/index.html b/server/app/templates/index.html
index c8bead1..ac4a9cc 100644
--- a/server/app/templates/index.html
+++ b/server/app/templates/index.html
@@ -207,9 +207,16 @@
// hold entirely (no scroll-vs-drag ambiguity with a mouse).
let dragState = null; // { pointerId, fromIndex, toIndex, moved, armed, holdTimer }
const DRAG_START_THRESHOLD_PX = 6; // ignore tiny jitter once armed, before committing to a drag
- const DRAG_HOLD_MS = 350;
+ const DRAG_HOLD_MS = 250;
const HOLD_CANCEL_THRESHOLD_PX = 10; // movement before the hold timer fires -> treat as a scroll, not a drag
+ function vibrate(ms) {
+ // Chrome/Android only -- iOS Safari has no Vibration API. Wrapped so
+ // it's a harmless no-op everywhere else rather than needing a
+ // feature check at every call site.
+ try { navigator.vibrate?.(ms); } catch (e) { /* ignore */ }
+ }
+
function clearDragOverStyling() {
document.querySelectorAll('.photo-card.drag-over').forEach((el) => el.classList.remove('drag-over'));
}
@@ -219,9 +226,11 @@
clearTimeout(dragState.holdTimer);
}
card.style.touchAction = '';
+ card.style.transform = '';
card.classList.remove('dragging', 'drag-armed');
clearDragOverStyling();
if (dragState && dragState.moved && dragState.toIndex !== undefined && dragState.toIndex !== dragState.fromIndex) {
+ vibrate(15);
moveItem(dragState.fromIndex, dragState.toIndex);
}
dragState = null;
@@ -287,6 +296,7 @@
dragState.armed = true;
card.classList.add('drag-armed');
card.style.touchAction = 'none';
+ vibrate(10);
try { card.setPointerCapture(e.pointerId); } catch (err) { /* pointer may have already left */ }
}
}, DRAG_HOLD_MS);
@@ -313,9 +323,10 @@
return;
}
+ const dx = e.clientX - dragState.startX;
+ const dy = e.clientY - dragState.startY;
+
if (!dragState.moved) {
- const dx = e.clientX - dragState.startX;
- const dy = e.clientY - dragState.startY;
if (Math.hypot(dx, dy) < DRAG_START_THRESHOLD_PX) {
return;
}
@@ -323,6 +334,12 @@
card.classList.remove('drag-armed');
card.classList.add('dragging');
}
+
+ // Follows the finger 1:1 -- the actual "pick it up and carry it"
+ // feedback that was missing before (the card used to just fade
+ // in place while a static outline highlighted the drop target).
+ card.style.transform = `translate(${dx}px, ${dy}px) scale(1.06)`;
+
const overCard = document.elementFromPoint(e.clientX, e.clientY)?.closest('.photo-card');
clearDragOverStyling();
if (overCard && overCard !== card) {