/* ============================================================
INDIC LEAGUE NEWS — Card Stack with full swipe physics
- Axis lock on first 6px (h vs v)
- Drag transform = translate(dx,0) rotate(dx*0.065deg)
- Threshold |dx| > 90 → fly-off; below → spring snap-back
- Left swipe = next; Right swipe = previous (per plan F06)
- tappedFab flag prevents FAB taps from triggering tap-to-open
- Web Audio sounds + Vibration haptics on transitions
============================================================ */
function ArticleCard({ article, isActive, onTapBody, onTapShare, onTapLike, onTapSave, onTapSkip, liked, saved, cardRef, onPointerDown }) {
// FABs need to NOT propagate as drag/tap. We set a marker on the card body.
const fabClick = (e, fn) => {
e.stopPropagation();
fn && fn();
};
return (
{article.badge}
{article.publishedAt}
{article.category}
{article.politicalConfidence >= 0.4 && (
{article.politicalLabel}
)}
{article.headline}
{article.summary}
{article.authorAvatar
?
: article.author.split(' ').map(w => w[0]).join('').slice(0, 2)}
By {article.author}
);
}
function GhostCard({ offset = 1, tint = 'peach' }) {
const transform = `translateY(${offset * 8}px) scale(${1 - offset * 0.035}) rotate(${offset === 1 ? -2.4 : 2.4}deg)`;
return (
);
}
/* ============================================================
The stack
============================================================ */
function CardStack({ articles, onOpenArticle, likedIds, savedIds, onLike, onSave, onShare, onRead }) {
const [idx, setIdx] = React.useState(0);
const [animating, setAnimating] = React.useState(false);
const [overlay, setOverlay] = React.useState(null); // 'next' | 'prev' | null
const [popFab, setPopFab] = React.useState(null); // 'like' | 'save' | 'skip' | null
const cardRef = React.useRef(null);
const dragRef = React.useRef({
active: false,
lockedAxis: null,
startX: 0,
startY: 0,
dx: 0,
dy: 0,
tappedFab: false,
pointerId: null,
});
const len = articles.length;
const current = articles[idx % len];
// Card entrance animation
const [entering, setEntering] = React.useState(true);
React.useEffect(() => {
setEntering(true);
const t = setTimeout(() => setEntering(false), 50);
return () => clearTimeout(t);
}, [idx]);
// Notify parent on each new active article
React.useEffect(() => {
if (current && onRead) onRead(current);
// eslint-disable-next-line
}, [current && current.id]);
// --- gesture helpers ---
const setCardTransform = (dx, dy = 0) => {
if (!cardRef.current) return;
const rot = dx * 0.06;
cardRef.current.style.transform = `translate(${dx}px, ${dy * 0.3}px) rotate(${rot}deg)`;
cardRef.current.style.transition = 'none';
};
const flyOff = (dir) => {
if (!cardRef.current) return;
setAnimating(true);
setOverlay(dir > 0 ? 'next' : 'prev');
window.IL_Sounds && window.IL_Sounds.swipeCommit(dir);
window.IL_Util && window.IL_Util.Haptics.swipeCommit();
cardRef.current.style.transition = 'transform 400ms cubic-bezier(0.55, 0, 1, 0.45), opacity 400ms';
cardRef.current.style.transform = `translate(${dir * 140}%, 0) rotate(${dir * 22}deg)`;
cardRef.current.style.opacity = '0';
setTimeout(() => {
// Advance/retreat in stack
setIdx((i) => dir > 0 ? (i + 1) % len : (i - 1 + len) % len);
setOverlay(null);
setAnimating(false);
if (cardRef.current) {
cardRef.current.style.transition = 'none';
cardRef.current.style.transform = '';
cardRef.current.style.opacity = '';
}
}, 380);
};
const snapBack = () => {
if (!cardRef.current) return;
setAnimating(true);
window.IL_Sounds && window.IL_Sounds.snapback();
cardRef.current.style.transition = 'transform 500ms cubic-bezier(0.175, 0.885, 0.32, 1.275)';
cardRef.current.style.transform = 'translate(0,0) rotate(0deg)';
setTimeout(() => {
setAnimating(false);
if (cardRef.current) cardRef.current.style.transition = 'none';
}, 480);
};
// --- pointer handlers (axis lock on first 6px) ---
const onPointerDown = (e) => {
if (animating) return;
// Ignore if target is inside .fab-row (handled below) or has data-no-drag
if (e.target.closest('[data-no-drag]')) return;
dragRef.current = {
active: true, lockedAxis: null,
startX: e.clientX, startY: e.clientY,
dx: 0, dy: 0, tappedFab: false,
pointerId: e.pointerId,
};
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e) => {
const s = dragRef.current;
if (!s.active) return;
const dx = e.clientX - s.startX;
const dy = e.clientY - s.startY;
s.dx = dx; s.dy = dy;
if (!s.lockedAxis) {
const adx = Math.abs(dx), ady = Math.abs(dy);
if (adx < 6 && ady < 6) return; // still in dead zone
s.lockedAxis = adx > ady ? 'h' : 'v';
}
if (s.lockedAxis === 'h') {
setCardTransform(dx, dy);
// Light tick haptic when crossing threshold
const past = Math.abs(dx) > 90;
if (past && !s.haptedAt90) {
s.haptedAt90 = true;
window.IL_Util && window.IL_Util.Haptics.light();
setOverlay(dx > 0 ? 'prev' : 'next');
} else if (!past && s.haptedAt90) {
s.haptedAt90 = false;
setOverlay(null);
}
}
};
const onPointerUp = (e) => {
const s = dragRef.current;
if (!s.active) return;
s.active = false;
// Tap detection: never locked, OR axis locked but trivial movement
const isTap = s.lockedAxis === null && Math.abs(s.dx) < 8 && Math.abs(s.dy) < 8;
if (isTap) {
// Only fire if not on FAB and not sponsored
onOpenArticle && onOpenArticle(current);
return;
}
if (s.lockedAxis === 'h' && Math.abs(s.dx) > 90) {
// Left swipe = next (dir = +1 → advance)
// Right swipe = previous (dir = -1 → retreat)
const dir = s.dx < 0 ? 1 : -1;
flyOff(dir);
} else {
setOverlay(null);
snapBack();
}
};
// --- Programmatic actions (also exposed on FABs) ---
const goNext = () => { if (!animating) flyOff(1); };
const goPrev = () => { if (!animating) flyOff(-1); };
const onLikeClick = () => {
setPopFab('like');
window.IL_Sounds && window.IL_Sounds.like();
window.IL_Util && window.IL_Util.Haptics.medium();
onLike && onLike(current);
setTimeout(() => setPopFab(null), 280);
};
const onSaveClick = () => {
setPopFab('save');
window.IL_Sounds && window.IL_Sounds.save();
window.IL_Util && window.IL_Util.Haptics.light();
onSave && onSave(current);
setTimeout(() => setPopFab(null), 280);
};
const onSkipClick = () => {
setPopFab('skip');
window.IL_Util && window.IL_Util.Haptics.light();
goNext();
setTimeout(() => setPopFab(null), 280);
};
const onShareClick = () => {
setPopFab('share');
window.IL_Sounds && window.IL_Sounds.tap();
window.IL_Util && window.IL_Util.Haptics.light();
onShare && onShare(current);
setTimeout(() => setPopFab(null), 280);
};
const onPrimaryClick = () => {
setPopFab('flame');
window.IL_Sounds && window.IL_Sounds.swipeCommit(1);
window.IL_Util && window.IL_Util.Haptics.medium();
goNext();
setTimeout(() => setPopFab(null), 280);
};
// Keyboard nav
React.useEffect(() => {
const onKey = (e) => {
if (e.key === 'ArrowLeft') goPrev();
if (e.key === 'ArrowRight') goNext();
if (e.key === 'l' || e.key === 'L') onLikeClick();
if (e.key === 's' || e.key === 'S') onSaveClick();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line
}, [animating, current && current.id]);
if (!current) return null;
const isLiked = likedIds && likedIds.has(current.id);
const isSaved = savedIds && savedIds.has(current.id);
return (
<>
{current.badge}
{current.publishedAt}
{current.category}
{current.politicalConfidence >= 0.4 && (
{current.politicalLabel}
)}
{current.headline}
{current.summary}
{current.authorAvatar
?
: current.author.split(' ').map(w => w[0]).join('').slice(0, 2)}
By {current.author}
{/* Swipe direction overlay */}
{overlay === 'next' && (
NEXT
)}
{overlay === 'prev' && (
PREVIOUS
)}
{/* Progress dots */}
{articles.slice(0, Math.min(len, 8)).map((_, i) => (
))}
{len > 8 && (
+{len - 8}
)}
{/* FABs */}
Swipe left for next, or tap to read full story
>
);
}
Object.assign(window, { CardStack, ArticleCard, GhostCard });