import React, { useState, useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; function useMediaQuery(query) { const [matches, setMatches] = useState(() => window.matchMedia(query).matches); useEffect(() => { const mql = window.matchMedia(query); const handler = (e) => setMatches(e.matches); mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); }, [query]); return matches; } function formatTime(ms, showCentiseconds = true) { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); if (!showCentiseconds) { return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } const cs = Math.floor((ms % 1000) / 10); return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(cs).padStart(2, '0')}`; } const COUNTDOWN_PRESETS = [ { label: '1:00', ms: 60000 }, { label: '2:00', ms: 120000 }, { label: '3:00', ms: 180000 }, { label: '5:00', ms: 300000 }, ]; export default function StopwatchWidget({ visible, onHide }) { const isDesktop = useMediaQuery('(min-width: 640px)'); // Mode: 'stopwatch' | 'timer' const [mode, setMode] = useState('stopwatch'); // Stopwatch state const [swRunning, setSwRunning] = useState(false); const [swElapsed, setSwElapsed] = useState(0); const [laps, setLaps] = useState([]); const swIntervalRef = useRef(null); const swStartRef = useRef(0); // Countdown state const [cdTarget, setCdTarget] = useState(60000); const [cdRemaining, setCdRemaining] = useState(60000); const [cdRunning, setCdRunning] = useState(false); const [cdExpired, setCdExpired] = useState(false); const [cdInputMin, setCdInputMin] = useState('1'); const [cdInputSec, setCdInputSec] = useState('00'); const cdIntervalRef = useRef(null); const cdStartRef = useRef(0); const cdRemainingAtStartRef = useRef(0); // Drag state (desktop only) const [position, setPosition] = useState({ x: window.innerWidth - 300, y: 80 }); const [isDragging, setIsDragging] = useState(false); const dragOffset = useRef({ x: 0, y: 0 }); const cardRef = useRef(null); // Stopwatch logic useEffect(() => { if (swRunning) { swStartRef.current = Date.now() - swElapsed; swIntervalRef.current = setInterval(() => { setSwElapsed(Date.now() - swStartRef.current); }, 10); } else { clearInterval(swIntervalRef.current); } return () => clearInterval(swIntervalRef.current); }, [swRunning]); const handleSwStartStop = () => setSwRunning(r => !r); const handleSwReset = () => { setSwRunning(false); setSwElapsed(0); setLaps([]); }; const handleSwLap = () => { setLaps(prev => [...prev, swElapsed]); }; // Countdown logic useEffect(() => { if (cdRunning) { cdStartRef.current = Date.now(); cdRemainingAtStartRef.current = cdRemaining; cdIntervalRef.current = setInterval(() => { const elapsed = Date.now() - cdStartRef.current; const remaining = cdRemainingAtStartRef.current - elapsed; if (remaining <= 0) { setCdRemaining(0); setCdRunning(false); setCdExpired(true); clearInterval(cdIntervalRef.current); } else { setCdRemaining(remaining); } }, 10); } else { clearInterval(cdIntervalRef.current); } return () => clearInterval(cdIntervalRef.current); }, [cdRunning]); const handleCdStartPause = () => { if (cdExpired) return; setCdRunning(r => !r); }; const handleCdReset = () => { setCdRunning(false); setCdRemaining(cdTarget); setCdExpired(false); }; const handleCdPreset = (ms) => { setCdRunning(false); setCdTarget(ms); setCdRemaining(ms); setCdExpired(false); setCdInputMin(String(Math.floor(ms / 60000))); setCdInputSec(String(Math.floor((ms % 60000) / 1000)).padStart(2, '0')); }; const handleCdCustomSet = () => { const min = parseInt(cdInputMin) || 0; const sec = parseInt(cdInputSec) || 0; const ms = (min * 60 + sec) * 1000; if (ms > 0) { setCdTarget(ms); setCdRemaining(ms); setCdRunning(false); setCdExpired(false); } }; // Expired flash effect useEffect(() => { if (cdExpired) { const timeout = setTimeout(() => setCdExpired(false), 3000); return () => clearTimeout(timeout); } }, [cdExpired]); // Drag handlers (desktop) const handleDragStart = useCallback((e) => { if (!isDesktop) return; const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; dragOffset.current = { x: clientX - position.x, y: clientY - position.y }; setIsDragging(true); }, [isDesktop, position]); useEffect(() => { if (!isDragging) return; const handleMove = (e) => { const clientX = e.touches ? e.touches[0].clientX : e.clientX; const clientY = e.touches ? e.touches[0].clientY : e.clientY; setPosition({ x: Math.max(0, Math.min(window.innerWidth - 280, clientX - dragOffset.current.x)), y: Math.max(0, Math.min(window.innerHeight - 100, clientY - dragOffset.current.y)), }); }; const handleUp = () => setIsDragging(false); document.addEventListener('mousemove', handleMove); document.addEventListener('mouseup', handleUp); document.addEventListener('touchmove', handleMove); document.addEventListener('touchend', handleUp); return () => { document.removeEventListener('mousemove', handleMove); document.removeEventListener('mouseup', handleUp); document.removeEventListener('touchmove', handleMove); document.removeEventListener('touchend', handleUp); }; }, [isDragging]); if (!visible) return null; const cardClasses = isDesktop ? 'fixed z-50 w-[280px] rounded-xl shadow-2xl' : 'fixed z-50 bottom-0 left-0 right-0 rounded-t-xl shadow-2xl'; const cardStyle = isDesktop ? { left: position.x, top: position.y } : {}; const borderColor = cdExpired ? 'border-red-500 animate-pulse' : 'border-gray-700 dark:border-gray-600'; const content = (
{/* Drag handle / header */}
{/* Display */}
{mode === 'stopwatch' ? formatTime(swElapsed) : formatTime(cdRemaining, false) }
{/* Controls */} {mode === 'stopwatch' ? (
{swRunning && ( )}
) : ( <>
{!cdRunning && ( <>
{COUNTDOWN_PRESETS.map(p => ( ))}
setCdInputMin(e.target.value)} className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white" /> m setCdInputSec(e.target.value)} className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white" /> s
)} )} {/* Lap list (stopwatch mode) */} {mode === 'stopwatch' && laps.length > 0 && (
{laps.map((lapTime, i) => { const prev = i === 0 ? 0 : laps[i - 1]; const delta = lapTime - prev; return (
#{i + 1} {formatTime(delta)} {formatTime(lapTime)}
); })}
)}
); return createPortal(content, document.body); }