"use client" import { dmSansClassName } from "@/lib/fonts" import { type InferredMemory, useInferredMemories, useInferredMemoryCache, useReviewInferredMemory, } from "@/hooks/use-inferred-memories" import { cn } from "@lib/utils" import { Dialog, DialogContent } from "@ui/components/dialog" import { Check, Undo2, X } from "lucide-react" import { AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react" import { useCallback, useEffect, useRef, useState } from "react" // Distance / velocity past which a drag commits to a decision. const SWIPE_OFFSET = 120 const SWIPE_VELOCITY = 600 const STACK_DEPTH = 3 // how many cards are visible at once type Decision = "approve" | "decline" | "skip" export function ReviewMemoriesModal({ open, onOpenChange, containerTag, }: { open: boolean onOpenChange: (open: boolean) => void containerTag: string | undefined }) { const { data: memories = [] } = useInferredMemories(containerTag) const { mutate: review } = useReviewInferredMemory(containerTag) const queue = useInferredMemoryCache(containerTag) const reduceMotion = useReducedMotion() // Snapshot the queue when the modal opens so cache updates from the // mutation don't reshuffle the stack mid-session. const [cards, setCards] = useState([]) const [index, setIndex] = useState(0) const [exitDir, setExitDir] = useState(1) const [approved, setApproved] = useState(0) const [history, setHistory] = useState<{ id: string; decision: Decision }[]>( [], ) // Synchronous source of truth so rapid swipes/undos don't read stale state. const indexRef = useRef(0) const historyRef = useRef<{ id: string; decision: Decision }[]>([]) // Intentionally keyed on `open` only: including `memories` would reset the // stack every time the review mutation trims the cache. The queue is loaded // by the time the trigger renders, so a snapshot on open is sufficient. // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot-on-open useEffect(() => { if (!open) return setCards(memories) setIndex(0) indexRef.current = 0 setApproved(0) setHistory([]) historyRef.current = [] }, [open]) const current = cards[index] const done = cards.length > 0 && index >= cards.length const decide = useCallback( (decision: Decision) => { const card = cards[indexRef.current] if (!card) return // 1 = fly right (approve), -1 = fly left (decline), 0 = drop down (skip) setExitDir(decision === "approve" ? 1 : decision === "decline" ? -1 : 0) if (decision === "approve") { setApproved((n) => n + 1) review({ memoryId: card.id, action: "approve" }) } else if (decision === "decline") { review({ memoryId: card.id, action: "decline" }) } else { // Skip persists nothing server-side, but drop it from the cached queue // so the trigger's live count falls and the prompt can be dismissed. queue.drop(card.id) } historyRef.current = [...historyRef.current, { id: card.id, decision }] setHistory(historyRef.current) indexRef.current += 1 setIndex(indexRef.current) }, [cards, review, queue.drop], ) const canUndo = history.length > 0 // Step back one card and revert its server-side review (skip had none). // Reads from refs so consecutive/rapid undos each target the correct card. const undo = useCallback(() => { const last = historyRef.current[historyRef.current.length - 1] if (!last) return historyRef.current = historyRef.current.slice(0, -1) setHistory(historyRef.current) if (last.decision === "approve") { setApproved((n) => Math.max(0, n - 1)) review({ memoryId: last.id, action: "undo" }) } else if (last.decision === "decline") { review({ memoryId: last.id, action: "undo" }) } else { const card = cards.find((c) => c.id === last.id) if (card) queue.restore(card) } indexRef.current = Math.max(0, indexRef.current - 1) setIndex(indexRef.current) }, [review, cards, queue.restore]) // Keyboard: ← decline, → approve, ↓/space skip. useEffect(() => { if (!open) return const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") { e.preventDefault() undo() return } if (done || !current) return if (e.key === "ArrowRight") decide("approve") else if (e.key === "ArrowLeft") decide("decline") else if (e.key === "ArrowDown" || e.key === " ") { e.preventDefault() decide("skip") } } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) }, [open, done, current, decide, undo]) const visible = cards.slice(index, index + STACK_DEPTH) return ( {/* Header */}

Suggested memories

Memories we inferred — keep the ones that fit

{/* Progress dots + undo + counter */} {cards.length > 0 && (
{cards.map((c, i) => ( ))}
{canUndo && ( )} {Math.min(index + 1, cards.length)}/{cards.length}
)} {/* Card deck */}
{/* Nova glow halo behind the deck */} {!done && (
)} {done ? ( ) : ( {visible.map((mem, i) => ( ))} )}
{/* Controls */} {!done && current && (
decide("decline")} whileHover={{ scale: 1.07 }} whileTap={{ scale: 0.9 }} transition={{ duration: 0.15 }} className="flex size-14 items-center justify-center rounded-full border border-[#ff6b6b]/30 bg-[#ff6b6b]/10 text-[#ff6b6b] transition-colors hover:bg-[#ff6b6b]/20" > decide("skip")} whileTap={{ scale: 0.94 }} transition={{ duration: 0.15 }} className="px-4 text-[13px] font-medium text-fg-faint transition-colors hover:text-fg-primary" > Skip decide("approve")} whileHover={{ scale: 1.07 }} whileTap={{ scale: 0.9 }} transition={{ duration: 0.15 }} className="flex size-14 items-center justify-center rounded-full border border-[#4ade80]/30 bg-[#4ade80]/10 text-[#4ade80] transition-colors hover:bg-[#4ade80]/20" >
)}
) } function SwipeCard({ memory, depth, globalIndex, isTop, reduceMotion, onDecide, }: { memory: InferredMemory depth: number globalIndex: number isTop: boolean reduceMotion: boolean onDecide: (d: Decision) => void }) { const x = useMotionValue(0) const rotate = useTransform(x, [-200, 200], reduceMotion ? [0, 0] : [-8, 8]) // Slack-style: the whole card washes green (keep) / red (decline) and the // border + verdict pill intensify the closer you get to committing. const wash = useTransform( x, [-150, -30, 0, 30, 150], [ "rgba(239,68,68,0.34)", "rgba(239,68,68,0.04)", "rgba(255,255,255,0)", "rgba(74,222,128,0.04)", "rgba(74,222,128,0.34)", ], ) const borderColor = useTransform( x, [-150, -30, 0, 30, 150], [ "rgba(239,68,68,0.7)", "rgba(255,255,255,0.08)", "rgba(255,255,255,0.08)", "rgba(255,255,255,0.08)", "rgba(74,222,128,0.7)", ], ) const keepOpacity = useTransform(x, [30, 120], [0, 1]) const keepScale = useTransform(x, [30, 120], [0.7, 1]) const declineOpacity = useTransform(x, [-120, -30], [1, 0]) const declineScale = useTransform(x, [-120, -30], [1, 0.7]) const scale = 1 - depth * 0.055 const y = depth * 16 return ( { if (reduceMotion) return { opacity: 0, transition: { duration: 0.25 } } // dir 0 = skip → drop straight down; ±1 = fly out sideways. if (dir === 0) return { y: 480, opacity: 0, scale: 0.9, transition: { duration: 0.45, ease: [0.4, 0, 0.2, 1] }, } return { x: dir * 540, opacity: 0, rotate: dir * 12, transition: { duration: 0.42, ease: [0.4, 0, 0.2, 1] }, } }, }} exit="exit" initial={{ scale: scale - 0.04, y: y + 10, opacity: 0 }} animate={{ scale, y, opacity: depth === 0 ? 1 : depth === 1 ? 0.8 : 0.45, }} transition={{ duration: 0.28, ease: [0.4, 0, 0.2, 1] }} drag={isTop ? "x" : false} dragSnapToOrigin dragElastic={0.7} whileDrag={{ scale: 1.02 }} onDragEnd={(_e, info) => { if (!isTop) return const { offset, velocity } = info if (offset.x > SWIPE_OFFSET || velocity.x > SWIPE_VELOCITY) onDecide("approve") else if (offset.x < -SWIPE_OFFSET || velocity.x < -SWIPE_VELOCITY) onDecide("decline") }} > {/* Full-card color wash that tracks the swipe */} {isTop && ( )} {/* Verdict pills — slide/scale in toward the swipe direction */} {isTop && ( <> Keep Decline )}
Inferred

{memory.memory}

{memory.parentCount > 0 && ( {memory.parentCount === 1 ? "from 1 memory" : `from ${memory.parentCount} memories`} )} {relativeTime(memory.createdAt)}
) } function DoneState({ approved, total }: { approved: number; total: number }) { return (

All caught up

{approved > 0 ? `You kept ${approved} of ${total} suggested ${total === 1 ? "memory" : "memories"}.` : "Nothing kept this time — they'll stay tucked away."}

) } function relativeTime(iso: string): string { const then = new Date(iso).getTime() if (Number.isNaN(then)) return "" const diff = Date.now() - then const day = 86_400_000 if (diff < day) return "today" if (diff < 2 * day) return "yesterday" if (diff < 7 * day) return `${Math.floor(diff / day)}d ago` if (diff < 30 * day) return `${Math.floor(diff / (7 * day))}w ago` return `${Math.floor(diff / (30 * day))}mo ago` }