feat: add PomodoroView, CountdownRing, QuickTimerBar, keyboard shortcuts
This commit is contained in:
parent
da4f3b5419
commit
6b46384304
68
web/src/components/CountdownRing.tsx
Normal file
68
web/src/components/CountdownRing.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
interface CountdownRingProps {
|
||||||
|
progress: number; // 0-1, where 1 = full, 0 = empty
|
||||||
|
size?: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
color?: string;
|
||||||
|
trackColor?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountdownRing({
|
||||||
|
progress,
|
||||||
|
size = 200,
|
||||||
|
strokeWidth = 8,
|
||||||
|
color,
|
||||||
|
trackColor = 'var(--cm-surface-muted)',
|
||||||
|
children,
|
||||||
|
}: CountdownRingProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference * (1 - Math.min(1, Math.max(0, progress)));
|
||||||
|
|
||||||
|
// Color transitions: green → yellow → orange → red
|
||||||
|
const dynamicColor = color ?? getProgressColor(progress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
{/* Track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={trackColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
{/* Progress */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={dynamicColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
className="transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/* Center content */}
|
||||||
|
{children && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressColor(progress: number): string {
|
||||||
|
if (progress > 0.6) return 'var(--cm-gentle)'; // green
|
||||||
|
if (progress > 0.3) return 'var(--cm-standard)'; // yellow
|
||||||
|
if (progress > 0.1) return 'var(--cm-important)'; // orange
|
||||||
|
return 'var(--cm-critical)'; // red
|
||||||
|
}
|
||||||
@ -1,24 +1,50 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useTimerStore } from '@/lib/store';
|
import { useTimerStore } from '@/lib/store';
|
||||||
import { useTickLoop } from '@/lib/use-tick';
|
import { useTickLoop } from '@/lib/use-tick';
|
||||||
|
import { useKeyboardShortcuts, SHORTCUT_MAP } from '@/lib/use-keyboard-shortcuts';
|
||||||
import { TimerCard } from './TimerCard';
|
import { TimerCard } from './TimerCard';
|
||||||
|
import { PomodoroView } from './PomodoroView';
|
||||||
|
import { QuickTimerBar } from './QuickTimerBar';
|
||||||
import { CreateTimerModal } from './CreateTimerModal';
|
import { CreateTimerModal } from './CreateTimerModal';
|
||||||
import { AlarmOverlay } from './AlarmOverlay';
|
import { AlarmOverlay } from './AlarmOverlay';
|
||||||
import { requestNotificationPermission } from '@/lib/notifications';
|
import { requestNotificationPermission } from '@/lib/notifications';
|
||||||
import { formatTime, formatDate } from '@/lib/format';
|
import { formatTime, formatDate } from '@/lib/format';
|
||||||
import { Plus, Clock, Bell } from 'lucide-react';
|
import { Plus, Clock, Bell, Keyboard } from 'lucide-react';
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const timers = useTimerStore((s) => s.timers);
|
const timers = useTimerStore((s) => s.timers);
|
||||||
const now = useTimerStore((s) => s.now);
|
const now = useTimerStore((s) => s.now);
|
||||||
|
const { pause, resume } = useTimerStore();
|
||||||
|
|
||||||
// Start the tick loop
|
// Start the tick loop
|
||||||
useTickLoop();
|
useTickLoop();
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
const getFirstActiveTimer = useCallback(() => {
|
||||||
|
return timers.find((t) => ['active', 'warning', 'paused'].includes(t.state));
|
||||||
|
}, [timers]);
|
||||||
|
|
||||||
|
useKeyboardShortcuts({
|
||||||
|
onNewTimer: () => setIsCreateOpen(true),
|
||||||
|
onQuickTimer: () => setIsCreateOpen(true),
|
||||||
|
onTogglePause: () => {
|
||||||
|
const t = getFirstActiveTimer();
|
||||||
|
if (!t) return;
|
||||||
|
if (t.state === 'paused') resume(t.id);
|
||||||
|
else if (t.type !== 'alarm') pause(t.id);
|
||||||
|
},
|
||||||
|
onDismiss: () => {
|
||||||
|
if (isCreateOpen) setIsCreateOpen(false);
|
||||||
|
else if (showShortcuts) setShowShortcuts(false);
|
||||||
|
},
|
||||||
|
onShowHelp: () => setShowShortcuts((p) => !p),
|
||||||
|
});
|
||||||
|
|
||||||
// Hydration guard
|
// Hydration guard
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@ -84,6 +110,14 @@ export function Dashboard() {
|
|||||||
<span className="text-sm font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
|
<span className="text-sm font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
{formatTime(now)} · {formatDate(now)}
|
{formatTime(now)} · {formatDate(now)}
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowShortcuts((p) => !p)}
|
||||||
|
className="p-2 rounded-lg transition-colors cursor-pointer"
|
||||||
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||||
|
title="Keyboard shortcuts (?)"
|
||||||
|
>
|
||||||
|
<Keyboard size={18} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCreateOpen(true)}
|
onClick={() => setIsCreateOpen(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer"
|
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer"
|
||||||
@ -95,8 +129,41 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Keyboard shortcuts overlay */}
|
||||||
|
{showShortcuts && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-black/60" onClick={() => setShowShortcuts(false)} />
|
||||||
|
<div
|
||||||
|
className="relative rounded-2xl border p-6 max-w-sm w-full mx-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{SHORTCUT_MAP.map((s) => (
|
||||||
|
<div key={s.key} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>{s.description}</span>
|
||||||
|
<kbd
|
||||||
|
className="px-2 py-1 rounded text-xs font-mono"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)' }}
|
||||||
|
>
|
||||||
|
{s.key}
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="max-w-3xl mx-auto px-4 py-6">
|
<main className="max-w-3xl mx-auto px-4 py-6">
|
||||||
|
{/* Quick timer bar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<QuickTimerBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Active timers */}
|
{/* Active timers */}
|
||||||
{activeTimers.length > 0 ? (
|
{activeTimers.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -105,9 +172,13 @@ export function Dashboard() {
|
|||||||
</h2>
|
</h2>
|
||||||
{activeTimers
|
{activeTimers
|
||||||
.sort((a, b) => a.targetTime - b.targetTime)
|
.sort((a, b) => a.targetTime - b.targetTime)
|
||||||
.map((timer) => (
|
.map((timer) =>
|
||||||
<TimerCard key={timer.id} timer={timer} />
|
timer.type === 'pomodoro' ? (
|
||||||
))}
|
<PomodoroView key={timer.id} timer={timer} />
|
||||||
|
) : (
|
||||||
|
<TimerCard key={timer.id} timer={timer} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
@ -144,6 +215,13 @@ export function Dashboard() {
|
|||||||
|
|
||||||
{/* Create Timer Modal */}
|
{/* Create Timer Modal */}
|
||||||
<CreateTimerModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
|
<CreateTimerModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="max-w-3xl mx-auto px-4 py-8 text-center">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
ChronoMind — Press <kbd className="px-1 py-0.5 rounded text-xs font-mono" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>?</kbd> for shortcuts
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
147
web/src/components/PomodoroView.tsx
Normal file
147
web/src/components/PomodoroView.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTimerStore } from '@/lib/store';
|
||||||
|
import { getRemainingMs } from '@/lib/timer-engine';
|
||||||
|
import type { Timer } from '@/lib/timer-engine';
|
||||||
|
import { CountdownRing } from './CountdownRing';
|
||||||
|
import { formatDuration } from '@/lib/format';
|
||||||
|
import { Coffee, Pause, Play, X, SkipForward } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PomodoroViewProps {
|
||||||
|
timer: Timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PomodoroView({ timer }: PomodoroViewProps) {
|
||||||
|
const now = useTimerStore((s) => s.now);
|
||||||
|
const { pause, resume, dismiss, advancePom } = useTimerStore();
|
||||||
|
|
||||||
|
const remaining = getRemainingMs(timer, now);
|
||||||
|
const duration = timer.duration ?? 1;
|
||||||
|
const progress = Math.max(0, remaining / duration);
|
||||||
|
|
||||||
|
const pomState = timer.pomodoroState;
|
||||||
|
const config = timer.pomodoroConfig;
|
||||||
|
if (!pomState || !config) return null;
|
||||||
|
|
||||||
|
const isBreak = pomState.isBreak || pomState.isLongBreak;
|
||||||
|
const isPaused = timer.state === 'paused';
|
||||||
|
const isFiring = timer.state === 'firing';
|
||||||
|
const roundLabel = isBreak
|
||||||
|
? pomState.isLongBreak ? 'Long Break' : 'Break'
|
||||||
|
: `Round ${pomState.currentRound} of ${config.rounds}`;
|
||||||
|
|
||||||
|
const ringColor = isBreak ? 'var(--cm-accent-secondary)' : 'var(--cm-accent)';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border p-6 text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
|
borderColor: 'var(--cm-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||||||
|
<Coffee size={18} style={{ color: isBreak ? 'var(--cm-accent-secondary)' : 'var(--cm-accent)' }} />
|
||||||
|
<span className="text-sm font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||||
|
{timer.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Round indicator */}
|
||||||
|
<div className="flex justify-center gap-1.5 mb-6">
|
||||||
|
{Array.from({ length: config.rounds }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-3 h-3 rounded-full transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
i < pomState.completedRounds
|
||||||
|
? 'var(--cm-success)'
|
||||||
|
: i === pomState.currentRound - 1 && !isBreak
|
||||||
|
? 'var(--cm-accent)'
|
||||||
|
: 'var(--cm-surface-muted)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Countdown ring */}
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<CountdownRing progress={progress} size={220} strokeWidth={10} color={ringColor}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div
|
||||||
|
className="text-4xl font-mono font-bold tabular-nums"
|
||||||
|
style={{ color: 'var(--cm-text-primary)' }}
|
||||||
|
>
|
||||||
|
{formatDuration(remaining)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{roundLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CountdownRing>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
{isPaused && (
|
||||||
|
<p className="text-sm mb-4 font-medium" style={{ color: 'var(--cm-warning)' }}>
|
||||||
|
Paused
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isFiring && (
|
||||||
|
<p className="text-sm mb-4 font-medium animate-pulse" style={{ color: 'var(--cm-critical)' }}>
|
||||||
|
{isBreak ? 'Break over! Start next round?' : 'Time\'s up! Take a break?'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
|
{(timer.state === 'active' || timer.state === 'warning') && (
|
||||||
|
<button
|
||||||
|
onClick={() => pause(timer.id)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||||
|
>
|
||||||
|
<Pause size={16} /> Pause
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPaused && (
|
||||||
|
<button
|
||||||
|
onClick={() => resume(timer.id)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
<Play size={16} /> Resume
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isFiring && (
|
||||||
|
<button
|
||||||
|
onClick={() => advancePom(timer.id)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
<SkipForward size={16} /> {isBreak ? 'Start Work' : 'Start Break'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => dismiss(timer.id)}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
|
||||||
|
>
|
||||||
|
<X size={16} /> End
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-4 flex justify-center gap-6 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
<span>Completed: {pomState.completedRounds}/{config.rounds}</span>
|
||||||
|
<span>Work: {config.workMinutes}m</span>
|
||||||
|
<span>Break: {config.breakMinutes}m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
web/src/components/QuickTimerBar.tsx
Normal file
53
web/src/components/QuickTimerBar.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTimerStore } from '@/lib/store';
|
||||||
|
import { Timer, Coffee, AlarmClock, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
const QUICK_PRESETS = [
|
||||||
|
{ label: '5m', durationMs: 5 * 60_000, icon: <Zap size={14} /> },
|
||||||
|
{ label: '15m', durationMs: 15 * 60_000, icon: <Timer size={14} /> },
|
||||||
|
{ label: '25m', durationMs: 25 * 60_000, icon: <Coffee size={14} /> },
|
||||||
|
{ label: '45m', durationMs: 45 * 60_000, icon: <Timer size={14} /> },
|
||||||
|
{ label: '1h', durationMs: 60 * 60_000, icon: <AlarmClock size={14} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function QuickTimerBar() {
|
||||||
|
const { addCountdown, addPomodoro } = useTimerStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{QUICK_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
onClick={() =>
|
||||||
|
addCountdown({
|
||||||
|
label: `${preset.label} Timer`,
|
||||||
|
durationMs: preset.durationMs,
|
||||||
|
urgency: 'standard',
|
||||||
|
cascade: { preset: 'light', intervals: [] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer hover:scale-105"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
|
color: 'var(--cm-text-secondary)',
|
||||||
|
border: '1px solid var(--cm-border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preset.icon} {preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => addPomodoro({ label: 'Focus Session' })}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer hover:scale-105"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(90, 140, 255, 0.1)',
|
||||||
|
color: 'var(--cm-accent)',
|
||||||
|
border: '1px solid rgba(90, 140, 255, 0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Coffee size={14} /> Pomodoro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
web/src/lib/use-keyboard-shortcuts.ts
Normal file
57
web/src/lib/use-keyboard-shortcuts.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// ── Keyboard Shortcuts ─────────────────────────────────────────
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ShortcutActions {
|
||||||
|
onNewTimer: () => void;
|
||||||
|
onQuickTimer: () => void;
|
||||||
|
onTogglePause: () => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
onShowHelp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(actions: ShortcutActions) {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
// Don't trigger shortcuts when typing in inputs
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 'n':
|
||||||
|
e.preventDefault();
|
||||||
|
actions.onNewTimer();
|
||||||
|
break;
|
||||||
|
case 'q':
|
||||||
|
e.preventDefault();
|
||||||
|
actions.onQuickTimer();
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
actions.onTogglePause();
|
||||||
|
break;
|
||||||
|
case 'escape':
|
||||||
|
actions.onDismiss();
|
||||||
|
break;
|
||||||
|
case '?':
|
||||||
|
e.preventDefault();
|
||||||
|
actions.onShowHelp();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [actions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SHORTCUT_MAP = [
|
||||||
|
{ key: 'N', description: 'New timer' },
|
||||||
|
{ key: 'Q', description: 'Quick timer modal' },
|
||||||
|
{ key: 'Space', description: 'Start/pause active timer' },
|
||||||
|
{ key: 'Esc', description: 'Dismiss alarm / close modal' },
|
||||||
|
{ key: '?', description: 'Show shortcuts' },
|
||||||
|
];
|
||||||
Loading…
Reference in New Issue
Block a user