diff --git a/src/components/JobListItem.tsx b/src/components/JobListItem.tsx index bee23dd..6c7d289 100644 --- a/src/components/JobListItem.tsx +++ b/src/components/JobListItem.tsx @@ -1,30 +1,193 @@ +import { useCallback, useEffect, useRef, useState } from "react" +import { XIcon } from "lucide-react" + import { Progress } from "@/components/ui/progress" import { cn } from "@/lib/utils" -import { formatTime } from "@/utils/datetime" +import { formatElapsed, formatTime } from "@/utils/datetime" + +const SWIPE_THRESHOLD = 100 +const CANCEL_THRESHOLD = 150 export interface JobListItemProps { title: string - runningTime: string progress: number startTime?: string eta?: string className?: string + onCancel?: () => void } -export function JobListItem({ title, runningTime, progress, startTime, eta, className }: JobListItemProps) { +export function JobListItem({ title, progress, startTime, eta, className, onCancel }: JobListItemProps) { + const [displayTime, setDisplayTime] = useState(() => + startTime ? formatElapsed(startTime) : 0, + ) + + const intervalRef = useRef | null>(null) + + // Swipe state + const [offsetX, setOffsetX] = useState(0) + const [isSwiping, setIsSwiping] = useState(false) + const [isDismissing, setIsDismissing] = useState(false) + const startXRef = useRef(0) + const startYRef = useRef(0) + const currentOffsetRef = useRef(0) + const isTrackingRef = useRef(false) + const directionLockedRef = useRef<"horizontal" | "vertical" | null>(null) + + useEffect(() => { + if (!startTime) return + + intervalRef.current = setInterval(() => { + setDisplayTime(formatElapsed(startTime)) + }, 1000) + + return () => { + if (intervalRef.current) clearInterval(intervalRef.current) + } + }, [startTime]) + + const handleCancel = useCallback(() => { + if (onCancel) { + onCancel() + } else { + console.log(`Cancel job: ${title}`) + } + }, [onCancel, title]) + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + // Only track primary pointer (left mouse / single touch) + if (e.button !== 0) return + startXRef.current = e.clientX + startYRef.current = e.clientY + isTrackingRef.current = true + directionLockedRef.current = null + currentOffsetRef.current = 0 + }, []) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!isTrackingRef.current) return + + const deltaX = e.clientX - startXRef.current + const deltaY = e.clientY - startYRef.current + + // Determine direction lock on first significant movement + if (!directionLockedRef.current) { + const absDx = Math.abs(deltaX) + const absDy = Math.abs(deltaY) + if (absDx < 5 && absDy < 5) return // Dead zone + + if (absDx > absDy) { + directionLockedRef.current = "horizontal" + setIsSwiping(true) + // Capture pointer to continue receiving events even outside element + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + } else { + directionLockedRef.current = "vertical" + isTrackingRef.current = false + return + } + } + + if (directionLockedRef.current !== "horizontal") return + + // Only allow swiping left (negative deltaX), with resistance for right swipe + const clampedOffset = deltaX > 0 ? deltaX * 0.2 : deltaX + currentOffsetRef.current = clampedOffset + setOffsetX(clampedOffset) + }, []) + + const handlePointerUp = useCallback(() => { + if (!isTrackingRef.current && !isSwiping) return + isTrackingRef.current = false + + const offset = currentOffsetRef.current + + if (offset < -CANCEL_THRESHOLD) { + // Dismiss: animate off-screen then trigger cancel + setIsDismissing(true) + setOffsetX(-window.innerWidth) + setTimeout(() => { + handleCancel() + setIsDismissing(false) + setOffsetX(0) + setIsSwiping(false) + }, 300) + } else { + // Snap back + setOffsetX(0) + setTimeout(() => { + setIsSwiping(false) + }, 200) + } + }, [isSwiping, handleCancel]) + + const handlePointerCancel = useCallback(() => { + isTrackingRef.current = false + directionLockedRef.current = null + setOffsetX(0) + setIsSwiping(false) + }, []) + + const swipeProgress = Math.min(Math.abs(offsetX) / SWIPE_THRESHOLD, 1) + const pastThreshold = Math.abs(offsetX) > SWIPE_THRESHOLD + return ( -
-
- {title} - {runningTime} +
+ {/* Background layer revealed on swipe */} +
+
- - {(startTime || eta) && ( + + {/* Foreground card */} +
- {startTime ? formatTime(startTime) : ""} started - {eta ? `ETA ${formatTime(eta)}` : ""} + {title} +
- )} + + {(startTime || eta) && ( +
+ {displayTime} + {eta ? `ETA ${formatTime(eta)}` : ""} +
+ )} +
) }