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:
parent
4d77e3f2bb
commit
acf689f97f
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue