Skip to content

Commit 5f3aa6f

Browse files
ChengaDevclaude
andcommitted
Add configurable keyboard shortcuts for shot clock operations
- Default keys: Horn=Q, Start/Stop=A, Reset14=D, Reset24=G, Clear=T, ↑↓=correction - Keyboard icon (top-right of clock panel) opens settings modal - Click-to-capture remapping: click a badge, press the new key - Duplicate detection: assigning a key clears the previous action - Escape cancels remapping without saving - Reset to defaults button - Key hints shown on every button - Shortcuts disabled when a text input is focused - Settings persisted in localStorage (sc_keybindings) - Works on /clock and /youtube pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c6d13dd commit 5f3aa6f

7 files changed

Lines changed: 517 additions & 7 deletions

File tree

src/components/Controls.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import styled from 'styled-components';
33

44
import { useLocalization } from '../contexts/Language/LanguageProvider';
5+
import { formatKey } from '../constants/defaultKeyBindings';
56

67
type ControlsProps = {
78
isTicking: boolean;
@@ -10,11 +11,18 @@ type ControlsProps = {
1011
on24SecondsClick: () => void;
1112
toggleDisplay: () => void;
1213
layout?: 'all' | 'clearOnly' | 'actionsOnly' | 'startOnly' | 'reset14Only' | 'reset24Only';
14+
keyLabels?: {
15+
startStop?: string;
16+
reset14?: string;
17+
reset24?: string;
18+
clear?: string;
19+
};
1320
};
1421

1522
function Buttons(props: ControlsProps) {
1623
const { locals } = useLocalization();
1724
const layout = props.layout ?? 'all';
25+
const kl = props.keyLabels ?? {};
1826

1927
const showStart = layout === 'all' || layout === 'actionsOnly' || layout === 'startOnly';
2028
const showReset14 = layout === 'all' || layout === 'actionsOnly' || layout === 'reset14Only';
@@ -26,23 +34,27 @@ function Buttons(props: ControlsProps) {
2634
{showStart && (
2735
<TimeToggleButton id='btnStart' onClick={props.onTickToggle} $isCurrentlyTicking={props.isTicking}>
2836
{props.isTicking ? locals.stopLabel : locals.startLabel}
37+
{kl.startStop && <KeyHint>{formatKey(kl.startStop)}</KeyHint>}
2938
</TimeToggleButton>
3039
)}
3140
{showReset14 && (
3241
<ResetButton id='btnReset14' onClick={props.on14SecondsClick}>
3342
<div>{locals.resetButtonText}</div>
3443
<div>14s</div>
44+
{kl.reset14 && <KeyHint>{formatKey(kl.reset14)}</KeyHint>}
3545
</ResetButton>
3646
)}
3747
{showClear && (
3848
<ClockButton id='btnToggleDisplay' onClick={props.toggleDisplay}>
3949
{locals.removeDisplayLabel}
50+
{kl.clear && <KeyHint>{formatKey(kl.clear)}</KeyHint>}
4051
</ClockButton>
4152
)}
4253
{showReset24 && (
4354
<ResetButton id='btnReset24' onClick={props.on24SecondsClick}>
4455
<div>{locals.resetButtonText}</div>
4556
<div>{locals.possessionLabel}</div>
57+
{kl.reset24 && <KeyHint>{formatKey(kl.reset24)}</KeyHint>}
4658
</ResetButton>
4759
)}
4860
</Container>
@@ -123,4 +135,13 @@ const ResetButton = styled(ClockButton)`
123135
}
124136
`;
125137

138+
export const KeyHint = styled.span`
139+
display: block;
140+
font-size: 0.6em;
141+
font-family: 'Courier New', monospace;
142+
opacity: 0.6;
143+
margin-top: 2px;
144+
line-height: 1;
145+
`;
146+
126147
export default Buttons;

src/components/Correction.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
11
import styled from 'styled-components'
2+
import { formatKey } from '../constants/defaultKeyBindings'
23

34
type CorrectionProps = {
45
decrementSecond: () => void
56
incrementSecond: () => void
7+
keyLabels?: {
8+
increment?: string
9+
decrement?: string
10+
}
611
}
712

813
const Correction = (props: CorrectionProps) => {
14+
const kl = props.keyLabels ?? {}
915
return (
1016
<Container>
1117
<AdjustButton onClick={props.decrementSecond} aria-label="Subtract one second">
1218
19+
{kl.decrement && <KeyHint>{formatKey(kl.decrement)}</KeyHint>}
1320
</AdjustButton>
1421
<CorrectionSign>C</CorrectionSign>
1522
<AdjustButton onClick={props.incrementSecond} aria-label="Add one second">
1623
+
24+
{kl.increment && <KeyHint>{formatKey(kl.increment)}</KeyHint>}
1725
</AdjustButton>
1826
</Container>
1927
)
@@ -58,6 +66,15 @@ const AdjustButton = styled.button`
5866
}
5967
`
6068

69+
const KeyHint = styled.span`
70+
display: block;
71+
font-size: 0.55em;
72+
font-family: 'Courier New', monospace;
73+
opacity: 0.6;
74+
margin-top: 1px;
75+
line-height: 1;
76+
`
77+
6178
const CorrectionSign = styled.span`
6279
font-size: 18px;
6380
font-weight: bold;
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import React, { useState, useEffect, useCallback } from 'react'
2+
import styled, { keyframes } from 'styled-components'
3+
import {
4+
KeyBindings,
5+
ActionKey,
6+
ACTION_LABELS,
7+
formatKey,
8+
} from '../constants/defaultKeyBindings'
9+
import { keyBindingsService } from '../services/keyBindingsService'
10+
11+
type Props = {
12+
bindings: KeyBindings
13+
onClose: () => void
14+
onSave: (bindings: KeyBindings) => void
15+
}
16+
17+
const BLOCKED_KEYS = [
18+
'Tab', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6',
19+
'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
20+
'Meta', 'Control', 'Alt', 'Shift', 'CapsLock',
21+
]
22+
23+
const ACTION_ORDER: ActionKey[] = [
24+
'horn', 'startStop', 'reset14', 'reset24', 'clear', 'incrementSecond', 'decrementSecond',
25+
]
26+
27+
const KeyboardSettingsModal: React.FC<Props> = ({ bindings, onClose, onSave }) => {
28+
const [local, setLocal] = useState<KeyBindings>({ ...bindings })
29+
const [listening, setListening] = useState<ActionKey | null>(null)
30+
31+
const handleKeyCapture = useCallback((e: KeyboardEvent) => {
32+
if (!listening) return
33+
e.preventDefault()
34+
e.stopPropagation()
35+
36+
if (e.key === 'Escape') { setListening(null); return }
37+
if (BLOCKED_KEYS.includes(e.key)) return
38+
if (e.metaKey || e.ctrlKey || e.altKey) return
39+
40+
const normalizeKey = (k: string) => k.length === 1 ? k.toLowerCase() : k
41+
const newKey = e.key
42+
43+
// Clear duplicate binding on other actions
44+
const updated: KeyBindings = { ...local }
45+
for (const action of ACTION_ORDER) {
46+
if (action !== listening && normalizeKey(updated[action]) === normalizeKey(newKey)) {
47+
updated[action] = ''
48+
}
49+
}
50+
updated[listening] = newKey
51+
52+
setLocal(updated)
53+
setListening(null)
54+
keyBindingsService.save(updated)
55+
onSave(updated)
56+
}, [listening, local, onSave])
57+
58+
useEffect(() => {
59+
if (!listening) return
60+
window.addEventListener('keydown', handleKeyCapture, true)
61+
return () => window.removeEventListener('keydown', handleKeyCapture, true)
62+
}, [listening, handleKeyCapture])
63+
64+
const handleReset = () => {
65+
const defaults = keyBindingsService.reset()
66+
setLocal(defaults)
67+
onSave(defaults)
68+
}
69+
70+
return (
71+
<Backdrop onClick={onClose}>
72+
<Modal onClick={e => e.stopPropagation()}>
73+
<ModalHeader>
74+
<KeyboardIcon />
75+
<ModalTitle>Keyboard Shortcuts</ModalTitle>
76+
<CloseButton onClick={onClose} aria-label="Close"></CloseButton>
77+
</ModalHeader>
78+
79+
<Hint>Click a key badge, then press the new key. Press Esc to cancel.</Hint>
80+
81+
<ActionList>
82+
{ACTION_ORDER.map(action => (
83+
<ActionRow key={action}>
84+
<ActionLabel>{ACTION_LABELS[action]}</ActionLabel>
85+
<KeyBadge
86+
$listening={listening === action}
87+
$empty={!local[action]}
88+
onClick={() => setListening(prev => prev === action ? null : action)}
89+
title="Click to remap"
90+
>
91+
{listening === action
92+
? 'press key…'
93+
: local[action] ? formatKey(local[action]) : '—'
94+
}
95+
</KeyBadge>
96+
</ActionRow>
97+
))}
98+
</ActionList>
99+
100+
<ModalFooter>
101+
<ResetButton onClick={handleReset}>Reset to defaults</ResetButton>
102+
</ModalFooter>
103+
</Modal>
104+
</Backdrop>
105+
)
106+
}
107+
108+
/* ── Icons ── */
109+
const KeyboardIcon = () => (
110+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
111+
<rect x="2" y="4" width="20" height="16" rx="2" />
112+
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10" />
113+
</svg>
114+
)
115+
116+
/* ── Animations ── */
117+
const fadeIn = keyframes`from { opacity: 0 } to { opacity: 1 }`
118+
const slideUp = keyframes`from { transform: translateY(16px); opacity: 0 } to { transform: translateY(0); opacity: 1 }`
119+
120+
/* ── Styled components ── */
121+
const Backdrop = styled.div`
122+
position: fixed;
123+
inset: 0;
124+
background: rgba(0, 0, 0, 0.6);
125+
backdrop-filter: blur(3px);
126+
z-index: 2000;
127+
display: flex;
128+
align-items: center;
129+
justify-content: center;
130+
animation: ${fadeIn} 0.15s ease;
131+
`
132+
133+
const Modal = styled.div`
134+
background: ${props => props.theme.cardBackground};
135+
border: 1px solid ${props => props.theme.cardBorder};
136+
border-radius: 16px;
137+
padding: 1.5rem;
138+
width: 100%;
139+
max-width: 380px;
140+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
141+
animation: ${slideUp} 0.2s ease;
142+
143+
@media (max-width: 440px) {
144+
margin: 0 1rem;
145+
}
146+
`
147+
148+
const ModalHeader = styled.div`
149+
display: flex;
150+
align-items: center;
151+
gap: 0.6rem;
152+
margin-bottom: 0.5rem;
153+
color: ${props => props.theme.text};
154+
`
155+
156+
const ModalTitle = styled.h2`
157+
font-family: 'Poppins', sans-serif;
158+
font-size: 1.1rem;
159+
font-weight: 700;
160+
margin: 0;
161+
flex: 1;
162+
color: ${props => props.theme.titleColor};
163+
`
164+
165+
const CloseButton = styled.button`
166+
background: none;
167+
border: none;
168+
color: ${props => props.theme.subtleText};
169+
font-size: 1.1rem;
170+
cursor: pointer;
171+
padding: 0.2rem 0.4rem;
172+
border-radius: 6px;
173+
line-height: 1;
174+
transition: color 0.15s;
175+
176+
&:hover { color: ${props => props.theme.text}; }
177+
`
178+
179+
const Hint = styled.p`
180+
font-size: 0.75rem;
181+
color: ${props => props.theme.subtleText};
182+
margin: 0 0 1.25rem;
183+
line-height: 1.4;
184+
`
185+
186+
const ActionList = styled.div`
187+
display: flex;
188+
flex-direction: column;
189+
gap: 0.5rem;
190+
`
191+
192+
const ActionRow = styled.div`
193+
display: flex;
194+
align-items: center;
195+
justify-content: space-between;
196+
padding: 0.5rem 0.75rem;
197+
border-radius: 8px;
198+
background: ${props => props.theme.mainBackgroundColor};
199+
`
200+
201+
const ActionLabel = styled.span`
202+
font-size: 0.88rem;
203+
font-weight: 500;
204+
color: ${props => props.theme.text};
205+
`
206+
207+
const KeyBadge = styled.button<{ $listening: boolean; $empty: boolean }>`
208+
min-width: 52px;
209+
padding: 0.3rem 0.6rem;
210+
border-radius: 6px;
211+
font-size: ${props => props.$listening ? '0.65rem' : '0.82rem'};
212+
font-weight: 700;
213+
font-family: 'Courier New', monospace;
214+
cursor: pointer;
215+
transition: all 0.15s;
216+
border: 2px solid ${props =>
217+
props.$listening
218+
? props.theme.accent
219+
: props.$empty
220+
? props.theme.cardBorder
221+
: props.theme.cardBorder};
222+
background: ${props =>
223+
props.$listening
224+
? `${props.theme.accent}18`
225+
: props.theme.cardBackground};
226+
color: ${props =>
227+
props.$listening
228+
? props.theme.accent
229+
: props.$empty
230+
? props.theme.subtleText
231+
: props.theme.text};
232+
white-space: nowrap;
233+
text-align: center;
234+
235+
&:hover:not(:disabled) {
236+
border-color: ${props => props.theme.accent};
237+
color: ${props => props.$listening ? props.theme.accent : props.theme.text};
238+
}
239+
`
240+
241+
const ModalFooter = styled.div`
242+
display: flex;
243+
justify-content: flex-end;
244+
margin-top: 1.25rem;
245+
padding-top: 1rem;
246+
border-top: 1px solid ${props => props.theme.cardBorder};
247+
`
248+
249+
const ResetButton = styled.button`
250+
background: none;
251+
border: 1.5px solid ${props => props.theme.cardBorder};
252+
color: ${props => props.theme.subtleText};
253+
border-radius: 8px;
254+
padding: 0.4rem 0.9rem;
255+
font-size: 0.8rem;
256+
cursor: pointer;
257+
transition: all 0.15s;
258+
259+
&:hover {
260+
border-color: ${props => props.theme.accent};
261+
color: ${props => props.theme.text};
262+
}
263+
`
264+
265+
export default KeyboardSettingsModal

0 commit comments

Comments
 (0)