2026-05-07 20:44:18 -04:00
|
|
|
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 },
|
|
|
|
|
];
|
|
|
|
|
|
2026-05-10 14:37:00 -04:00
|
|
|
const ALARM_SOUNDS = [
|
|
|
|
|
{
|
|
|
|
|
id: 'digital',
|
|
|
|
|
label: 'Digital Beep',
|
|
|
|
|
play: () => {
|
|
|
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
const beep = (startTime) => {
|
|
|
|
|
const osc = ctx.createOscillator();
|
|
|
|
|
const gain = ctx.createGain();
|
|
|
|
|
osc.connect(gain);
|
|
|
|
|
gain.connect(ctx.destination);
|
|
|
|
|
osc.frequency.value = 830;
|
|
|
|
|
osc.type = 'square';
|
|
|
|
|
gain.gain.value = 0.3;
|
|
|
|
|
osc.start(startTime);
|
|
|
|
|
osc.stop(startTime + 0.15);
|
|
|
|
|
};
|
|
|
|
|
beep(ctx.currentTime);
|
|
|
|
|
beep(ctx.currentTime + 0.25);
|
|
|
|
|
beep(ctx.currentTime + 0.5);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'chime',
|
|
|
|
|
label: 'Gentle Chime',
|
|
|
|
|
play: () => {
|
|
|
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
[523, 659, 784].forEach((freq, i) => {
|
|
|
|
|
const osc = ctx.createOscillator();
|
|
|
|
|
const gain = ctx.createGain();
|
|
|
|
|
osc.connect(gain);
|
|
|
|
|
gain.connect(ctx.destination);
|
|
|
|
|
osc.frequency.value = freq;
|
|
|
|
|
osc.type = 'sine';
|
|
|
|
|
gain.gain.setValueAtTime(0.25, ctx.currentTime + i * 0.3);
|
|
|
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.3 + 0.4);
|
|
|
|
|
osc.start(ctx.currentTime + i * 0.3);
|
|
|
|
|
osc.stop(ctx.currentTime + i * 0.3 + 0.4);
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'urgent',
|
|
|
|
|
label: 'Urgent Alarm',
|
|
|
|
|
play: () => {
|
|
|
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
|
|
|
const osc = ctx.createOscillator();
|
|
|
|
|
const gain = ctx.createGain();
|
|
|
|
|
osc.connect(gain);
|
|
|
|
|
gain.connect(ctx.destination);
|
|
|
|
|
osc.frequency.value = 1000;
|
|
|
|
|
osc.type = 'sawtooth';
|
|
|
|
|
gain.gain.value = 0.2;
|
|
|
|
|
osc.start(ctx.currentTime + i * 0.12);
|
|
|
|
|
osc.stop(ctx.currentTime + i * 0.12 + 0.08);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'bell',
|
|
|
|
|
label: 'Bell',
|
|
|
|
|
play: () => {
|
|
|
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
|
const osc = ctx.createOscillator();
|
|
|
|
|
const gain = ctx.createGain();
|
|
|
|
|
osc.connect(gain);
|
|
|
|
|
gain.connect(ctx.destination);
|
|
|
|
|
osc.frequency.value = 660;
|
|
|
|
|
osc.type = 'sine';
|
|
|
|
|
gain.gain.setValueAtTime(0.4, ctx.currentTime);
|
|
|
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5);
|
|
|
|
|
osc.start(ctx.currentTime);
|
|
|
|
|
osc.stop(ctx.currentTime + 1.5);
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const inputClass = 'w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none';
|
|
|
|
|
|
2026-05-07 20:44:18 -04:00
|
|
|
export default function StopwatchWidget({ visible, onHide }) {
|
|
|
|
|
const isDesktop = useMediaQuery('(min-width: 640px)');
|
|
|
|
|
|
2026-05-10 14:37:00 -04:00
|
|
|
const [mode, setMode] = useState('timer');
|
2026-05-07 20:44:18 -04:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
2026-05-10 14:37:00 -04:00
|
|
|
// Sound state
|
|
|
|
|
const [soundEnabled, setSoundEnabled] = useState(true);
|
|
|
|
|
const [selectedSound, setSelectedSound] = useState('digital');
|
|
|
|
|
|
2026-05-07 20:44:18 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-10 14:37:00 -04:00
|
|
|
// Expired flash + sound
|
2026-05-07 20:44:18 -04:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (cdExpired) {
|
2026-05-10 14:37:00 -04:00
|
|
|
if (soundEnabled) {
|
|
|
|
|
const sound = ALARM_SOUNDS.find(s => s.id === selectedSound);
|
|
|
|
|
if (sound) sound.play();
|
|
|
|
|
}
|
2026-05-07 20:44:18 -04:00
|
|
|
const timeout = setTimeout(() => setCdExpired(false), 3000);
|
|
|
|
|
return () => clearTimeout(timeout);
|
|
|
|
|
}
|
2026-05-10 14:37:00 -04:00
|
|
|
}, [cdExpired, soundEnabled, selectedSound]);
|
|
|
|
|
|
|
|
|
|
const handlePreviewSound = () => {
|
|
|
|
|
const sound = ALARM_SOUNDS.find(s => s.id === selectedSound);
|
|
|
|
|
if (sound) sound.play();
|
|
|
|
|
};
|
2026-05-07 20:44:18 -04:00
|
|
|
|
|
|
|
|
// 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 = (
|
|
|
|
|
<div
|
|
|
|
|
ref={cardRef}
|
|
|
|
|
className={`${cardClasses} bg-gray-900 dark:bg-gray-950 border-2 ${borderColor} text-white select-none`}
|
|
|
|
|
style={cardStyle}
|
|
|
|
|
>
|
|
|
|
|
{/* Drag handle / header */}
|
|
|
|
|
<div
|
|
|
|
|
className={`flex items-center justify-between px-3 py-2 ${isDesktop ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
|
|
|
|
onMouseDown={handleDragStart}
|
|
|
|
|
onTouchStart={handleDragStart}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setMode('stopwatch')}
|
|
|
|
|
className={`px-2 py-1 text-xs rounded transition ${mode === 'stopwatch' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
|
|
|
|
|
>
|
|
|
|
|
Stopwatch
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setMode('timer')}
|
|
|
|
|
className={`px-2 py-1 text-xs rounded transition ${mode === 'timer' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
|
|
|
|
|
>
|
|
|
|
|
Timer
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onHide}
|
|
|
|
|
className="w-7 h-7 sm:w-6 sm:h-6 flex items-center justify-center text-gray-400 hover:text-white hover:bg-gray-700 rounded transition"
|
|
|
|
|
title="Hide"
|
|
|
|
|
>
|
|
|
|
|
✕
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Display */}
|
2026-05-10 14:37:00 -04:00
|
|
|
<div className="px-3 pb-3">
|
2026-05-07 20:44:18 -04:00
|
|
|
<div className="bg-black/40 rounded-lg px-4 py-3 sm:py-2 text-center mb-3">
|
|
|
|
|
<span
|
|
|
|
|
className={`font-mono tracking-wider ${isDesktop ? 'text-2xl' : 'text-3xl'} ${mode === 'timer' && cdRemaining < 10000 && cdRunning ? 'text-red-400' : 'text-green-400'}`}
|
|
|
|
|
style={{ fontFamily: "'Courier New', monospace" }}
|
|
|
|
|
>
|
|
|
|
|
{mode === 'stopwatch'
|
|
|
|
|
? formatTime(swElapsed)
|
|
|
|
|
: formatTime(cdRemaining, false)
|
|
|
|
|
}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Controls */}
|
|
|
|
|
{mode === 'stopwatch' ? (
|
|
|
|
|
<div className="flex gap-2 mb-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSwStartStop}
|
|
|
|
|
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition ${swRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700'}`}
|
|
|
|
|
>
|
|
|
|
|
{swRunning ? 'Stop' : 'Start'}
|
|
|
|
|
</button>
|
|
|
|
|
{swRunning && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSwLap}
|
|
|
|
|
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-blue-600 hover:bg-blue-700 transition"
|
|
|
|
|
>
|
|
|
|
|
Lap
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSwReset}
|
|
|
|
|
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
|
|
|
|
|
>
|
|
|
|
|
Reset
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex gap-2 mb-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleCdStartPause}
|
|
|
|
|
disabled={cdExpired || cdRemaining <= 0}
|
|
|
|
|
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition disabled:opacity-50 ${cdRunning ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-green-600 hover:bg-green-700'}`}
|
|
|
|
|
>
|
|
|
|
|
{cdRunning ? 'Pause' : 'Start'}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleCdReset}
|
|
|
|
|
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
|
|
|
|
|
>
|
|
|
|
|
Reset
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{!cdRunning && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="flex gap-1 mb-2">
|
|
|
|
|
{COUNTDOWN_PRESETS.map(p => (
|
|
|
|
|
<button
|
|
|
|
|
key={p.ms}
|
|
|
|
|
onClick={() => handleCdPreset(p.ms)}
|
|
|
|
|
className={`flex-1 py-1 rounded text-xs font-medium transition ${cdTarget === p.ms ? 'bg-indigo-600' : 'bg-gray-700 hover:bg-gray-600'}`}
|
|
|
|
|
>
|
|
|
|
|
{p.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1 text-xs">
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
max="99"
|
|
|
|
|
value={cdInputMin}
|
|
|
|
|
onChange={(e) => setCdInputMin(e.target.value)}
|
2026-05-10 14:37:00 -04:00
|
|
|
className={inputClass}
|
2026-05-07 20:44:18 -04:00
|
|
|
/>
|
|
|
|
|
<span className="text-gray-400">m</span>
|
|
|
|
|
<input
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
max="59"
|
|
|
|
|
value={cdInputSec}
|
|
|
|
|
onChange={(e) => setCdInputSec(e.target.value)}
|
2026-05-10 14:37:00 -04:00
|
|
|
className={inputClass}
|
2026-05-07 20:44:18 -04:00
|
|
|
/>
|
|
|
|
|
<span className="text-gray-400">s</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleCdCustomSet}
|
|
|
|
|
className="ml-1 px-2 py-0.5 bg-indigo-600 hover:bg-indigo-700 rounded text-xs font-medium transition"
|
|
|
|
|
>
|
|
|
|
|
Set
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-10 14:37:00 -04:00
|
|
|
|
|
|
|
|
{/* Sound controls */}
|
|
|
|
|
<div className="flex items-center gap-2 mt-3 pt-2 border-t border-gray-700/50">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setSoundEnabled(s => !s)}
|
|
|
|
|
className={`p-1 rounded transition ${soundEnabled ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-500 hover:text-gray-400'}`}
|
|
|
|
|
title={soundEnabled ? 'Mute alarm' : 'Enable alarm sound'}
|
|
|
|
|
>
|
|
|
|
|
{soundEnabled ? (
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M17.95 6.05a8 8 0 010 11.9M6.5 8H4a1 1 0 00-1 1v6a1 1 0 001 1h2.5l4.5 4V4l-4.5 4z" />
|
|
|
|
|
</svg>
|
|
|
|
|
) : (
|
|
|
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
|
|
|
|
|
</svg>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<select
|
|
|
|
|
value={selectedSound}
|
|
|
|
|
onChange={(e) => setSelectedSound(e.target.value)}
|
|
|
|
|
disabled={!soundEnabled}
|
|
|
|
|
className="w-full appearance-none bg-gray-800 border border-gray-600 rounded px-2 py-1 pr-6 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed transition"
|
|
|
|
|
>
|
|
|
|
|
{ALARM_SOUNDS.map(s => (
|
|
|
|
|
<option key={s.id} value={s.id}>{s.label}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<svg className="absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={handlePreviewSound}
|
|
|
|
|
disabled={!soundEnabled}
|
|
|
|
|
className="p-1 rounded text-gray-400 hover:text-white transition disabled:opacity-40 disabled:cursor-not-allowed"
|
|
|
|
|
title="Preview sound"
|
|
|
|
|
>
|
|
|
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path d="M8 5v14l11-7z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-05-07 20:44:18 -04:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Lap list (stopwatch mode) */}
|
|
|
|
|
{mode === 'stopwatch' && laps.length > 0 && (
|
|
|
|
|
<div className={`mt-2 border-t border-gray-700 pt-2 overflow-y-auto ${isDesktop ? 'max-h-32' : 'max-h-24'}`}>
|
|
|
|
|
{laps.map((lapTime, i) => {
|
|
|
|
|
const prev = i === 0 ? 0 : laps[i - 1];
|
|
|
|
|
const delta = lapTime - prev;
|
|
|
|
|
return (
|
|
|
|
|
<div key={i} className="flex justify-between text-xs text-gray-300 py-0.5">
|
|
|
|
|
<span className="text-gray-500">#{i + 1}</span>
|
|
|
|
|
<span className="font-mono">{formatTime(delta)}</span>
|
|
|
|
|
<span className="font-mono text-gray-500">{formatTime(lapTime)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return createPortal(content, document.body);
|
|
|
|
|
}
|