fix: UI improvements and multiple keyboard shortcuts support

- Add theme toggle to Projects page header (#689)
- Fix nested button HTML validation errors in header and hero (#687)
  - Use asChild prop to render Links with button styles
- Add ability to add multiple keyboard shortcuts per action (#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>
This commit is contained in:
Abelino Chavez 2026-01-30 20:31:25 -06:00
parent 4d77e3f2bb
commit acf689f97f
4 changed files with 117 additions and 45 deletions

View File

@ -56,6 +56,7 @@ import {
import { DeleteProjectDialog } from "@/components/editor/dialogs/delete-project-dialog";
import { ProjectInfoDialog } from "@/components/editor/dialogs/project-info-dialog";
import { RenameProjectDialog } from "@/components/editor/dialogs/rename-project-dialog";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/utils/ui";
const formatProjectDuration = ({
@ -175,6 +176,7 @@ function ProjectsHeader() {
<div className="flex items-center gap-3 md:gap-4">
<SearchBar className="hidden md:block" />
<NewProjectButton />
<ThemeToggle />
</div>
</div>
<SearchBar className="block md:hidden mb-4" />

View File

@ -16,6 +16,15 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ShortcutKey } from "@/types/keybinding";
interface RecordingState {
shortcut: KeyboardShortcut;
mode: "add" | "replace";
keyToReplace?: ShortcutKey;
}
export function ShortcutsDialog({
isOpen,
@ -24,15 +33,15 @@ export function ShortcutsDialog({
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [recordingShortcut, setRecordingShortcut] =
useState<KeyboardShortcut | null>(null);
const [recordingState, setRecordingState] = useState<RecordingState | null>(
null,
);
const {
updateKeybinding,
removeKeybinding,
getKeybindingString,
validateKeybinding,
getKeybindingsForAction,
setIsRecording,
resetToDefaults,
isRecording,
@ -43,7 +52,7 @@ export function ShortcutsDialog({
const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
useEffect(() => {
if (!isRecording || !recordingShortcut) return;
if (!isRecording || !recordingState) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
@ -53,30 +62,31 @@ export function ShortcutsDialog({
if (keyString) {
const conflict = validateKeybinding(
keyString,
recordingShortcut.action,
recordingState.shortcut.action,
);
if (conflict) {
toast.error(
`Key "${keyString}" is already bound to "${conflict.existingAction}"`,
);
setRecordingShortcut(null);
setRecordingState(null);
setIsRecording(false);
return;
}
const oldKeys = getKeybindingsForAction(recordingShortcut.action);
for (const key of oldKeys) {
removeKeybinding(key);
// Only remove the specific key being replaced, not all keys
if (recordingState.mode === "replace" && recordingState.keyToReplace) {
removeKeybinding(recordingState.keyToReplace);
}
updateKeybinding(keyString, recordingShortcut.action);
updateKeybinding(keyString, recordingState.shortcut.action);
setIsRecording(false);
setRecordingShortcut(null);
setRecordingState(null);
}
};
const handleClickOutside = () => {
setRecordingShortcut(null);
setRecordingState(null);
setIsRecording(false);
};
@ -88,21 +98,29 @@ export function ShortcutsDialog({
document.removeEventListener("click", handleClickOutside);
};
}, [
recordingShortcut,
recordingState,
getKeybindingString,
updateKeybinding,
removeKeybinding,
validateKeybinding,
getKeybindingsForAction,
setIsRecording,
isRecording,
]);
const handleStartRecording = (shortcut: KeyboardShortcut) => {
setRecordingShortcut(shortcut);
const handleStartReplacing = (shortcut: KeyboardShortcut, key: ShortcutKey) => {
setRecordingState({ shortcut, mode: "replace", keyToReplace: key });
setIsRecording(true);
};
const handleStartAdding = (shortcut: KeyboardShortcut) => {
setRecordingState({ shortcut, mode: "add" });
setIsRecording(true);
};
const handleRemoveKey = (key: ShortcutKey) => {
removeKeybinding(key);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
@ -125,9 +143,14 @@ export function ShortcutsDialog({
key={shortcut.action}
shortcut={shortcut}
isRecording={
shortcut.action === recordingShortcut?.action
shortcut.action === recordingState?.shortcut.action
}
onStartRecording={() => handleStartRecording(shortcut)}
recordingMode={recordingState?.mode}
onStartReplacing={(key: ShortcutKey) =>
handleStartReplacing(shortcut, key)
}
onStartAdding={() => handleStartAdding(shortcut)}
onRemoveKey={handleRemoveKey}
/>
))}
</div>
@ -148,11 +171,17 @@ export function ShortcutsDialog({
function ShortcutItem({
shortcut,
isRecording,
onStartRecording,
recordingMode,
onStartReplacing,
onStartAdding,
onRemoveKey,
}: {
shortcut: KeyboardShortcut;
isRecording: boolean;
onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
recordingMode?: "add" | "replace";
onStartReplacing: (key: ShortcutKey) => void;
onStartAdding: () => void;
onRemoveKey: (key: ShortcutKey) => void;
}) {
const displayKeys = shortcut.keys.filter((key: string) => {
if (
@ -164,8 +193,14 @@ function ShortcutItem({
return true;
});
// Get raw keys for remove functionality (before formatting)
const { keybindings } = useKeybindingsStore();
const rawKeys = Object.entries(keybindings)
.filter(([, action]) => action === shortcut.action)
.map(([key]) => key as ShortcutKey);
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-3">
{shortcut.icon && (
<div className="text-muted-foreground">{shortcut.icon}</div>
@ -178,11 +213,17 @@ function ShortcutItem({
<div className="flex items-center gap-1">
{key.split("+").map((keyPart: string, partIndex: number) => {
const keyId = `${shortcut.id}-${index}-${partIndex}`;
const rawKey = rawKeys[index];
return (
<EditableShortcutKey
key={keyId}
isRecording={isRecording}
onStartRecording={() => onStartRecording({ shortcut })}
isRecording={isRecording && recordingMode === "replace"}
onStartRecording={() => rawKey && onStartReplacing(rawKey)}
onRemove={
displayKeys.length > 1 && rawKey
? () => onRemoveKey(rawKey)
: undefined
}
>
{keyPart}
</EditableShortcutKey>
@ -194,6 +235,19 @@ function ShortcutItem({
)}
</div>
))}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onStartAdding();
}}
title="Add another shortcut"
className={isRecording && recordingMode === "add" ? "ring-2 ring-primary" : ""}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-3" />
</Button>
</div>
</div>
);
@ -203,10 +257,12 @@ function EditableShortcutKey({
children,
isRecording,
onStartRecording,
onRemove,
}: {
children: React.ReactNode;
isRecording: boolean;
onStartRecording: () => void;
onRemove?: () => void;
}) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
@ -214,13 +270,27 @@ function EditableShortcutKey({
onStartRecording();
};
const handleRightClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (onRemove) {
onRemove();
}
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
onContextMenu={handleRightClick}
className={isRecording ? "ring-2 ring-primary" : ""}
title={
isRecording ? "Press any key combination..." : "Click to edit shortcut"
isRecording
? "Press any key combination..."
: onRemove
? "Click to edit, right-click to remove"
: "Click to edit shortcut"
}
>
{children}

View File

@ -46,11 +46,11 @@ export function Header() {
</Link>
<nav className="hidden items-center gap-4 md:flex">
{links.map((link) => (
<Link key={link.href} href={link.href}>
<Button variant="text" className="p-0 text-sm">
<Button key={link.href} variant="text" className="p-0 text-sm" asChild>
<Link href={link.href}>
{link.label}
</Button>
</Link>
</Link>
</Button>
))}
</nav>
</div>
@ -67,18 +67,18 @@ export function Header() {
</Button>
</div>
<div className="hidden items-center gap-3 md:flex">
<Link href={SOCIAL_LINKS.github}>
<Button className="bg-background text-sm" variant="outline">
<Button className="bg-background text-sm" variant="outline" asChild>
<Link href={SOCIAL_LINKS.github}>
<HugeiconsIcon icon={GithubIcon} className="size-4" />
40k+
</Button>
</Link>
<Link href="/projects">
<Button variant="foreground" className="text-sm">
</Link>
</Button>
<Button variant="foreground" className="text-sm" asChild>
<Link href="/projects">
Projects
<ArrowRight className="size-4" />
</Button>
</Link>
</Link>
</Button>
<ThemeToggle />
</div>
</div>

View File

@ -28,17 +28,17 @@ export function Hero() {
</p>
<div className="mt-8 flex justify-center gap-8">
<Link href="/projects">
<Button
variant="foreground"
type="submit"
size="lg"
className="h-11 text-base"
>
<Button
variant="foreground"
size="lg"
className="h-11 text-base"
asChild
>
<Link href="/projects">
Try early beta
<ArrowRight className="ml-0.5" />
</Button>
</Link>
</Link>
</Button>
</div>
</div>
</div>