diff --git a/web/src/components/focus/FocusCardStack.tsx b/web/src/components/focus/FocusCardStack.tsx index 2600576..032d481 100644 --- a/web/src/components/focus/FocusCardStack.tsx +++ b/web/src/components/focus/FocusCardStack.tsx @@ -6,12 +6,6 @@ import { FocusCard } from './FocusCard'; import { ReaderView } from '../ReaderView'; import { MobileReaderView } from '../MobileReaderView'; import { useIsMobile } from '../../hooks/useIsMobile'; -// The CardStack props don't have onToggleFavorite. We might need to add it or Mock it. -// ReaderView requires onToggleFavorite. -// Let's check FocusCardStackProps. It does NOT have it. -// We should add it to props or just use a dummy one for now if not strictly required by user? -// User said "meme system". Dashboard passes `toggleFavoriteMutation.mutate`. -// FocusPage handles mutations. We should pass `onToggleFavorite` to FocusCardStack. interface FocusCardStackProps { articles: Article[]; @@ -21,33 +15,185 @@ interface FocusCardStackProps { onEmpty: () => void; } +// ------------------------------------------------------------------ +// Internal Component: SwipeableCard +// Isolates drag state per card to prevent "rebound" on unmount. +// ------------------------------------------------------------------ +interface SwipeableCardProps { + article: Article; + index: number; + isTop: boolean; + onAction: (action: 'read' | 'keep') => void; + onOpen: () => void; + hasDraggedRef: React.MutableRefObject; + isProcessing: boolean; +} + +function SwipeableCard({ article, index, isTop, onAction, onOpen, hasDraggedRef, isProcessing }: SwipeableCardProps) { + const x = useMotionValue(0); + const rotate = useTransform(x, [-200, 200], [-10, 10]); + + // Background color based on drag direction (Keep/Read indicators) + const bgOpacityRead = useTransform(x, [0, 150], [0, 1]); + const bgOpacityKeep = useTransform(x, [-150, 0], [1, 0]); + + // Spring physics for smooth movement + const xSpring = useSpring(x, { stiffness: 1000, damping: 50 }); + const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 }); + + const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => { + if (!isTop || isProcessing) return; // Only top card is draggable + + if (active && Math.abs(mx) > 5) { + hasDraggedRef.current = true; + } + + const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50); + + if (!active && trigger) { + // Swipe Validated + const isRight = mx > 0; + // Capture nothing here, we just fire action. + // The component unmounts with its current x/xSpring state intact. + // Motion handles the rest via 'exit' prop or just unmounting. + onAction(isRight ? 'read' : 'keep'); + } else if (!active) { + // Snap Back + x.set(0); + setTimeout(() => { hasDraggedRef.current = false; }, 50); + } else { + // Dragging + x.set(mx); + } + }); + + // Determine zIndex and scale based on index + const zIndex = 100 - index; + const scale = 1 - index * 0.05; + const yOffset = index * 20; + + return ( + Wait, xSpring is horizontal movement applied to Y? + // Ah, the original code had: y: isTop ? xSpring : i * 20. + // That seems weird if xSpring comes from x (horizontal). + // Maybe it was intended to give a slight vertical lift on drag? + // Or maybe xSpring was actually ySpring in previous code? + // In my reading of previous code: const x = useMotionValue(0); const xSpring = useSpring(x...); + // So y was bound to xSpring. That means swiping right moves the card DOWN? + // That might be a typo in original code or a specific effect. + // Let's stick to X for horizontal. + // Let's look at original again: `y: isTop ? xSpring : i * 20`. + // If I swipe right (x > 0), y increases (card goes down). + // If I swipe left (x < 0), y decreases (card goes up). + // Use 'x' for 'x'. And 'y' for... 0 if top? + // Let's use xSpring for 'x' property. + + x: isTop ? xSpring : 0, + rotate: isTop ? rotateSpring : 0, + zIndex: zIndex, + cursor: isTop ? 'grab' : 'default', + }} + {...(isTop ? (bind() as any) : {})} // Bind gesture only if top + onDoubleClick={() => { + if (isTop && !hasDraggedRef.current) { + onOpen(); + } + }} + initial={{ scale: scale, y: yOffset, x: 0 }} + animate={{ scale: scale, y: yOffset, x: 0 }} // Re-center if index changes (but this component is unique by key) + // Correction: When index changes (e.g. going from 1 to 0), we want it to animate to new position. + // But here we are mapping articles. + // If we use 'key={article.id}', the component identity is preserved. + // So if card at index 1 becomes index 0, it animates: + // scale: 0.95 -> 1 + // y: 20 -> 0 + // x: 0 -> 0 + + exit={{ + // When unmounting (swiped out): + // We want it to keep its current x/rotation roughly, and fade out. + // Since xSpring holds the value, we don't need to force x in exit if we rely on the fact + // that unmounting preserves style for the exit duration? + // Actually, Framer Motion exit prop overrides. + // If we want to freeze, we can use a custom exit. + // But the user complained about rebound because 'x' changed. + // Here, 'x' is LOCAL. And we NEVER reset it if we trigger action. + // So 'xSpring' will stay at ~500 or -500. + // So we can just fade out. + opacity: 0, + scale: 0.9, + transition: { duration: 0.2 } + }} + > + {/* Visual Indicators embedded in card to move with it? + Actually original code had them in background. + If we want them ON the card, we put them here. + If in background, parent handles. + Original code: Indicators were OUTSIDE the card stack, using 'bgOpacityRead' derived from 'x'. + Since 'x' is now local, the parent doesn't know about it. + We should either: + 1. Expose 'x' to parent (complex). + 2. Put indicators INSIDE the card (but absolute positioned to fill screen? No, clipped). + 3. Put indicators INSIDE the card but visual only on the card itself (like Tinder stamps). + + Let's emulate the original "Background Indicators" but maybe just on the card itself is better for encapsulation? + Or we can use a callback `onDrag` to update parent state? Performance hit. + + Simpler: The user wanted "Swipe card". The indicators were "Background". + If we lose the background indicators, is it bad? + The original code had: + + + With isolated state, we can't easily drive standard DOM elements outside the component. + But we CAN put the indicators INSIDE the card container? + No, because the card rotates and moves. + + Alternative: We only show indicators ON THE CARD (Overlay). + Like a "NOPE" or "LIKE" stamp on the card content. + Let's add that. It's often better UX anyway. + */} + + {/* Overlay Indicators (Like Tinder) */} + +
+ J'M +
+
+ + +
+ Garder +
+
+ + +
+ ); +} + +// ------------------------------------------------------------------ +// Main Component +// ------------------------------------------------------------------ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onEmpty }: FocusCardStackProps) { const [currentIndex, setCurrentIndex] = useState(0); const [readingArticle, setReadingArticle] = useState
(null); - const [exitX, setExitX] = useState(0); const isMobile = useIsMobile(); - const [isProcessing, setIsProcessing] = useState(false); - // Track whether a drag gesture occurred to prevent double-click from firing after swipe + + // Shared ref just for the double click check logic if needed, + // but actually each card manages its own drag state. + // We pass this ref down so the active card can write to it. const hasDraggedRef = useRef(false); // Visible stack size const visibleArticles = articles.slice(currentIndex, currentIndex + 3); const topArticle = visibleArticles[0]; - // Motion values for drag - const x = useMotionValue(0); - const rotate = useTransform(x, [-200, 200], [-10, 10]); - // Opacity not used for card itself in this design, removed to fix lint - - // Background color based on drag direction - const bgOpacityRead = useTransform(x, [0, 150], [0, 1]); - const bgOpacityKeep = useTransform(x, [-150, 0], [1, 0]); - - // Spring physics for smooth return - const xSpring = useSpring(x, { stiffness: 1000, damping: 50 }); - const rotateSpring = useSpring(rotate, { stiffness: 1000, damping: 50 }); - const handleAction = (action: 'read' | 'keep' | 'undo') => { if (isProcessing) return; setIsProcessing(true); @@ -55,48 +201,23 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, if (action === 'undo') { if (currentIndex > 0) { setCurrentIndex(prev => Math.max(0, prev - 1)); - x.set(0); } } else if (action === 'read') { onMarkRead(topArticle.id); setCurrentIndex(prev => prev + 1); - x.set(0); } else { onKeep(topArticle.id); setCurrentIndex(prev => prev + 1); - x.set(0); } // Lock for animation duration setTimeout(() => setIsProcessing(false), 500); }; - const bind = useDrag(({ active, movement: [mx], velocity: [vx] }) => { - if (readingArticle || isProcessing) return; // Disable drag when reading or processing - - // Mark as dragged if the user moved more than a few pixels - if (active && Math.abs(mx) > 5) { - hasDraggedRef.current = true; - } - - const trigger = Math.abs(mx) > 200 || (Math.abs(vx) > 0.5 && Math.abs(mx) > 50); - - if (!active && trigger) { - // Swipe Validé - const isRight = mx > 0; - setExitX(x.get()); // Capture current position to freeze exit animation - - // Trigger action via handler - handleAction(isRight ? 'read' : 'keep'); - } else if (!active) { - // Snap Back — reset drag flag after a short delay so double-click can distinguish - x.set(0); - setTimeout(() => { hasDraggedRef.current = false; }, 50); - } else { - // Actively dragging - x.set(mx); - } - }); + // Callbacks for the card components + const handleCardAction = (action: 'read' | 'keep') => { + handleAction(action); + }; // Check for empty stack useEffect(() => { @@ -118,50 +239,11 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, onClose={() => setReadingArticle(null)} onToggleFavorite={onToggleFavorite} onNext={() => { - onMarkRead(readingArticle.id); - setCurrentIndex(prev => prev + 1); + handleAction('read'); setReadingArticle(null); }} onPrev={() => { - onKeep(readingArticle.id); - // MobileReaderView 'Next' usually goes forward. 'Prev' goes back? - // Actually in MobileReaderView context, swipe left = Next, swipe Right = Prev. - // Here 'Prev' in the context of Focus Stack might mean 'Keep' or 'Undo'? - // The user's request is "validation" (swipe right? no left usually implies next) - // Let's map Next -> MarkRead (Next card), Prev -> Keep (Skip/Next card but keep). - // Wait, usually swipe left = next item. - // In Tinter: Left = Dislike (skip), Right = Like (Keep/Read). - // In Focus: Right = Validé (Read), Left = Keep (Skip). - // So 'Next' (Swipe Left) should map to 'Keep'? - // Let's check MobileReaderView implementation. - // onSwipedLeft: () => onNext() - // onSwipedRight: () => onPrev() - // If we want to consistency with FocusCardStack: - // Stack: Swipe Right (>0) = Mark Read. Swipe Left (<0) = Keep. - // MobileReaderView: Swipe Left (Next). Swipe Right (Prev). - // So MobileReaderView 'Next' (Left) should be 'Keep'. - // MobileReaderView 'Prev' (Right) should be 'Mark Read'. - // But 'Prev' name is confusing. - // Let's stick to: - // Next -> onKeep (Skip) - // Prev -> onMarkRead (Read) - - // Actually, let's just close and advance index, logic is handled by what we call. - // If 'Next' means 'I'm done, show next', it's 'Mark Read'. - // Let's map: - // onNext (Left Swipe) -> Keep (Skip to next) - // onPrev (Right Swipe) -> Mark Read (Done, to next) - // This matches the card stack directions: - // Card Stack: Left Swipe (mx < 0) -> Keep. - // MobileReaderView: Left Swipe -> onNext. - // So onNext === Keep. - - // Card Stack: Right Swipe (mx > 0) -> Mark Read. - // MobileReaderView: Right Swipe -> onPrev. - // So onPrev === Mark Read. - - onKeep(readingArticle.id); - setCurrentIndex(prev => prev + 1); + handleAction('keep'); setReadingArticle(null); }} /> @@ -175,50 +257,30 @@ export function FocusCardStack({ articles, onMarkRead, onKeep, onToggleFavorite, )} - {/* Background Indicators */} - - ✓ - - - ⭐ - + {/* Note: Background indicators removed in favor of Card Overlays for isolated state cleanliness */} {/* Card Stack */}
{visibleArticles.map((article, i) => { - const isTop = i === 0; return ( - { - // Only open on genuine double-click, not after a drag gesture - if (isTop && !hasDraggedRef.current) { - setReadingArticle(article); - } - }} - animate={{ scale: 1 - i * 0.05, y: i * 20 }} - exit={{ x: exitX, opacity: 0, scale: 0.9, transition: { duration: 0.2 } }} - > - - + article={article} + index={i} + isTop={i === 0} + onAction={handleCardAction} + onOpen={() => setReadingArticle(article)} + hasDraggedRef={hasDraggedRef} + isProcessing={isProcessing} + /> ); })}
- {/* Garder — Outlined style matching Dashboard "Actualiser" button */} + {/* Garder */} - {/* Undo — Subtle outlined, invisible when unavailable */} + {/* Undo */} - {/* J'ai lu — Primary filled, matching "Lancer le Mode Focus" CTA */} + {/* J'ai lu */}