Skip to content

Commit 7983a29

Browse files
nino-chavezclaude
andcommitted
fix: UI improvements and multiple keyboard shortcuts support
- Add theme toggle to Projects page header (OpenCut-app#689) - Fix nested button HTML validation errors in header and hero (OpenCut-app#687) - Use asChild prop to render Links with button styles - Add ability to add multiple keyboard shortcuts per action (OpenCut-app#696) - New "+" button to add additional shortcuts - Replace mode only removes the specific key being edited - Right-click to remove a shortcut when multiple exist Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7bf0984 commit 7983a29

File tree

4 files changed

+117
-45
lines changed

4 files changed

+117
-45
lines changed

apps/web/src/app/projects/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
5757
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
5858
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
59+
import { ThemeToggle } from "@/components/theme-toggle";
5960
import { cn } from "@/utils/ui";
6061

6162
const formatProjectDuration = ({
@@ -181,6 +182,7 @@ function ProjectsHeader() {
181182
<div className="flex items-center gap-3 md:gap-4">
182183
<SearchBar className="hidden md:block" />
183184
<NewProjectButton />
185+
<ThemeToggle />
184186
</div>
185187
</div>
186188
<SearchBar className="block md:hidden mb-4" />

apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx

Lines changed: 94 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ import {
1616
DialogHeader,
1717
DialogTitle,
1818
} from "@/components/ui/dialog";
19+
import { PlusSignIcon } from "@hugeicons/core-free-icons";
20+
import { HugeiconsIcon } from "@hugeicons/react";
21+
import type { ShortcutKey } from "@/types/keybinding";
22+
23+
interface RecordingState {
24+
shortcut: KeyboardShortcut;
25+
mode: "add" | "replace";
26+
keyToReplace?: ShortcutKey;
27+
}
1928

2029
export function ShortcutsDialog({
2130
isOpen,
@@ -24,15 +33,15 @@ export function ShortcutsDialog({
2433
isOpen: boolean;
2534
onOpenChange: (open: boolean) => void;
2635
}) {
27-
const [recordingShortcut, setRecordingShortcut] =
28-
useState<KeyboardShortcut | null>(null);
36+
const [recordingState, setRecordingState] = useState<RecordingState | null>(
37+
null,
38+
);
2939

3040
const {
3141
updateKeybinding,
3242
removeKeybinding,
3343
getKeybindingString,
3444
validateKeybinding,
35-
getKeybindingsForAction,
3645
setIsRecording,
3746
resetToDefaults,
3847
isRecording,
@@ -43,7 +52,7 @@ export function ShortcutsDialog({
4352
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
4453

4554
useEffect(() => {
46-
if (!isRecording || !recordingShortcut) return;
55+
if (!isRecording || !recordingState) return;
4756

4857
const handleKeyDown = (e: KeyboardEvent) => {
4958
e.preventDefault();
@@ -53,30 +62,31 @@ export function ShortcutsDialog({
5362
if (keyString) {
5463
const conflict = validateKeybinding(
5564
keyString,
56-
recordingShortcut.action,
65+
recordingState.shortcut.action,
5766
);
5867
if (conflict) {
5968
toast.error(
6069
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
6170
);
62-
setRecordingShortcut(null);
71+
setRecordingState(null);
72+
setIsRecording(false);
6373
return;
6474
}
6575

66-
const oldKeys = getKeybindingsForAction(recordingShortcut.action);
67-
for (const key of oldKeys) {
68-
removeKeybinding(key);
76+
// Only remove the specific key being replaced, not all keys
77+
if (recordingState.mode === "replace" && recordingState.keyToReplace) {
78+
removeKeybinding(recordingState.keyToReplace);
6979
}
7080

71-
updateKeybinding(keyString, recordingShortcut.action);
81+
updateKeybinding(keyString, recordingState.shortcut.action);
7282

7383
setIsRecording(false);
74-
setRecordingShortcut(null);
84+
setRecordingState(null);
7585
}
7686
};
7787

7888
const handleClickOutside = () => {
79-
setRecordingShortcut(null);
89+
setRecordingState(null);
8090
setIsRecording(false);
8191
};
8292

@@ -88,21 +98,29 @@ export function ShortcutsDialog({
8898
document.removeEventListener("click", handleClickOutside);
8999
};
90100
}, [
91-
recordingShortcut,
101+
recordingState,
92102
getKeybindingString,
93103
updateKeybinding,
94104
removeKeybinding,
95105
validateKeybinding,
96-
getKeybindingsForAction,
97106
setIsRecording,
98107
isRecording,
99108
]);
100109

101-
const handleStartRecording = (shortcut: KeyboardShortcut) => {
102-
setRecordingShortcut(shortcut);
110+
const handleStartReplacing = (shortcut: KeyboardShortcut, key: ShortcutKey) => {
111+
setRecordingState({ shortcut, mode: "replace", keyToReplace: key });
103112
setIsRecording(true);
104113
};
105114

115+
const handleStartAdding = (shortcut: KeyboardShortcut) => {
116+
setRecordingState({ shortcut, mode: "add" });
117+
setIsRecording(true);
118+
};
119+
120+
const handleRemoveKey = (key: ShortcutKey) => {
121+
removeKeybinding(key);
122+
};
123+
106124
return (
107125
<Dialog open={isOpen} onOpenChange={onOpenChange}>
108126
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
@@ -125,9 +143,14 @@ export function ShortcutsDialog({
125143
key={shortcut.action}
126144
shortcut={shortcut}
127145
isRecording={
128-
shortcut.action === recordingShortcut?.action
146+
shortcut.action === recordingState?.shortcut.action
147+
}
148+
recordingMode={recordingState?.mode}
149+
onStartReplacing={(key: ShortcutKey) =>
150+
handleStartReplacing(shortcut, key)
129151
}
130-
onStartRecording={() => handleStartRecording(shortcut)}
152+
onStartAdding={() => handleStartAdding(shortcut)}
153+
onRemoveKey={handleRemoveKey}
131154
/>
132155
))}
133156
</div>
@@ -148,11 +171,17 @@ export function ShortcutsDialog({
148171
function ShortcutItem({
149172
shortcut,
150173
isRecording,
151-
onStartRecording,
174+
recordingMode,
175+
onStartReplacing,
176+
onStartAdding,
177+
onRemoveKey,
152178
}: {
153179
shortcut: KeyboardShortcut;
154180
isRecording: boolean;
155-
onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
181+
recordingMode?: "add" | "replace";
182+
onStartReplacing: (key: ShortcutKey) => void;
183+
onStartAdding: () => void;
184+
onRemoveKey: (key: ShortcutKey) => void;
156185
}) {
157186
const displayKeys = shortcut.keys.filter((key: string) => {
158187
if (
@@ -164,8 +193,14 @@ function ShortcutItem({
164193
return true;
165194
});
166195

196+
// Get raw keys for remove functionality (before formatting)
197+
const { keybindings } = useKeybindingsStore();
198+
const rawKeys = Object.entries(keybindings)
199+
.filter(([, action]) => action === shortcut.action)
200+
.map(([key]) => key as ShortcutKey);
201+
167202
return (
168-
<div className="flex items-center justify-between">
203+
<div className="flex items-center justify-between py-1">
169204
<div className="flex items-center gap-3">
170205
{shortcut.icon && (
171206
<div className="text-muted-foreground">{shortcut.icon}</div>
@@ -178,11 +213,17 @@ function ShortcutItem({
178213
<div className="flex items-center gap-1">
179214
{key.split("+").map((keyPart: string, partIndex: number) => {
180215
const keyId = `${shortcut.id}-${index}-${partIndex}`;
216+
const rawKey = rawKeys[index];
181217
return (
182218
<EditableShortcutKey
183219
key={keyId}
184-
isRecording={isRecording}
185-
onStartRecording={() => onStartRecording({ shortcut })}
220+
isRecording={isRecording && recordingMode === "replace"}
221+
onStartRecording={() => rawKey && onStartReplacing(rawKey)}
222+
onRemove={
223+
displayKeys.length > 1 && rawKey
224+
? () => onRemoveKey(rawKey)
225+
: undefined
226+
}
186227
>
187228
{keyPart}
188229
</EditableShortcutKey>
@@ -194,6 +235,19 @@ function ShortcutItem({
194235
)}
195236
</div>
196237
))}
238+
<Button
239+
variant="outline"
240+
size="sm"
241+
onClick={(e) => {
242+
e.preventDefault();
243+
e.stopPropagation();
244+
onStartAdding();
245+
}}
246+
title="Add another shortcut"
247+
className={isRecording && recordingMode === "add" ? "ring-2 ring-primary" : ""}
248+
>
249+
<HugeiconsIcon icon={PlusSignIcon} className="size-3" />
250+
</Button>
197251
</div>
198252
</div>
199253
);
@@ -203,24 +257,40 @@ function EditableShortcutKey({
203257
children,
204258
isRecording,
205259
onStartRecording,
260+
onRemove,
206261
}: {
207262
children: React.ReactNode;
208263
isRecording: boolean;
209264
onStartRecording: () => void;
265+
onRemove?: () => void;
210266
}) {
211267
const handleClick = (e: React.MouseEvent) => {
212268
e.preventDefault();
213269
e.stopPropagation();
214270
onStartRecording();
215271
};
216272

273+
const handleRightClick = (e: React.MouseEvent) => {
274+
e.preventDefault();
275+
e.stopPropagation();
276+
if (onRemove) {
277+
onRemove();
278+
}
279+
};
280+
217281
return (
218282
<Button
219283
variant="outline"
220284
size="sm"
221285
onClick={handleClick}
286+
onContextMenu={handleRightClick}
287+
className={isRecording ? "ring-2 ring-primary" : ""}
222288
title={
223-
isRecording ? "Press any key combination..." : "Click to edit shortcut"
289+
isRecording
290+
? "Press any key combination..."
291+
: onRemove
292+
? "Click to edit, right-click to remove"
293+
: "Click to edit shortcut"
224294
}
225295
>
226296
{children}

apps/web/src/components/header.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ export function Header() {
4646
</Link>
4747
<nav className="hidden items-center gap-4 md:flex">
4848
{links.map((link) => (
49-
<Link key={link.href} href={link.href}>
50-
<Button variant="text" className="p-0 text-sm">
49+
<Button key={link.href} variant="text" className="p-0 text-sm" asChild>
50+
<Link href={link.href}>
5151
{link.label}
52-
</Button>
53-
</Link>
52+
</Link>
53+
</Button>
5454
))}
5555
</nav>
5656
</div>
@@ -67,18 +67,18 @@ export function Header() {
6767
</Button>
6868
</div>
6969
<div className="hidden items-center gap-3 md:flex">
70-
<Link href={SOCIAL_LINKS.github}>
71-
<Button className="bg-background text-sm" variant="outline">
70+
<Button className="bg-background text-sm" variant="outline" asChild>
71+
<Link href={SOCIAL_LINKS.github}>
7272
<HugeiconsIcon icon={GithubIcon} className="size-4" />
7373
40k+
74-
</Button>
75-
</Link>
76-
<Link href="/projects">
77-
<Button variant="foreground" className="text-sm">
74+
</Link>
75+
</Button>
76+
<Button variant="foreground" className="text-sm" asChild>
77+
<Link href="/projects">
7878
Projects
7979
<ArrowRight className="size-4" />
80-
</Button>
81-
</Link>
80+
</Link>
81+
</Button>
8282
<ThemeToggle />
8383
</div>
8484
</div>

apps/web/src/components/landing/hero.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,17 @@ export function Hero() {
2828
</p>
2929

3030
<div className="mt-8 flex justify-center gap-8">
31-
<Link href="/projects">
32-
<Button
33-
variant="foreground"
34-
type="submit"
35-
size="lg"
36-
className="h-11 text-base"
37-
>
31+
<Button
32+
variant="foreground"
33+
size="lg"
34+
className="h-11 text-base"
35+
asChild
36+
>
37+
<Link href="/projects">
3838
Try early beta
3939
<ArrowRight className="ml-0.5" />
40-
</Button>
41-
</Link>
40+
</Link>
41+
</Button>
4242
</div>
4343
</div>
4444
</div>

0 commit comments

Comments
 (0)