Keyboard shortcut support (#3890)
* Add keyboard shortcuts help feature * Minor lint fixes * Enhance keyboard shortcuts feature by updating help shortcut and adding keyboard shortcuts button in Quick Links. Include new translation for keyboard shortcuts in locale. * Added documentation of keyboard shortcuts * refactor keyboard shortcuts to not render on every page (like login) limit shortcuts to admins update locales remove keyboard shortcut button - move to resources remove readme entry * move translation key --------- Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
parent
07e5b70f13
commit
2cabb2fad8
@ -17,6 +17,7 @@ import { PfpProvider } from "./PfpContext";
|
||||
import { LogoProvider } from "./LogoContext";
|
||||
import { FullScreenLoader } from "./components/Preloader";
|
||||
import { ThemeProvider } from "./ThemeContext";
|
||||
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
|
||||
|
||||
const Main = lazy(() => import("@/pages/Main"));
|
||||
const InvitePage = lazy(() => import("@/pages/Invite"));
|
||||
@ -269,6 +270,7 @@ export default function App() {
|
||||
/>
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
<KeyboardShortcutsHelp />
|
||||
</I18nextProvider>
|
||||
</PfpProvider>
|
||||
</LogoProvider>
|
||||
|
||||
60
frontend/src/components/KeyboardShortcutsHelp/index.jsx
Normal file
60
frontend/src/components/KeyboardShortcutsHelp/index.jsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { X } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SHORTCUTS,
|
||||
isMac,
|
||||
KEYBOARD_SHORTCUTS_HELP_EVENT,
|
||||
} from "@/utils/keyboardShortcuts";
|
||||
|
||||
export default function KeyboardShortcutsHelp() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, () =>
|
||||
setIsOpen((prev) => !prev)
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, () =>
|
||||
setIsOpen(false)
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-auto bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="relative bg-theme-bg-secondary rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{t("keyboard-shortcuts.title")}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-white hover:text-gray-300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(SHORTCUTS).map(([key, shortcut]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-3 bg-theme-bg-hover rounded-lg"
|
||||
>
|
||||
<span className="text-white">
|
||||
{t(`keyboard-shortcuts.shortcuts.${shortcut.translationKey}`)}
|
||||
</span>
|
||||
<kbd className="px-2 py-1 bg-theme-bg-secondary text-white rounded border border-gray-600">
|
||||
{isMac ? key : key.replace("⌘", "Ctrl")}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
|
||||
import { userFromStorage } from "@/utils/request";
|
||||
import System from "@/models/system";
|
||||
import UserMenu from "../UserMenu";
|
||||
import { KeyboardShortcutWrapper } from "@/utils/keyboardShortcuts";
|
||||
|
||||
// Used only for Multi-user mode only as we permission specific pages based on auth role.
|
||||
// When in single user mode we just bypass any authchecks.
|
||||
@ -95,11 +96,15 @@ export function AdminRoute({ Component, hideUserMenu = false }) {
|
||||
const user = userFromStorage();
|
||||
return isAuthd && (user?.role === "admin" || !multiUserMode) ? (
|
||||
hideUserMenu ? (
|
||||
<KeyboardShortcutWrapper>
|
||||
<Component />
|
||||
</KeyboardShortcutWrapper>
|
||||
) : (
|
||||
<KeyboardShortcutWrapper>
|
||||
<UserMenu>
|
||||
<Component />
|
||||
</UserMenu>
|
||||
</KeyboardShortcutWrapper>
|
||||
)
|
||||
) : (
|
||||
<Navigate to={paths.home()} />
|
||||
@ -119,9 +124,11 @@ export function ManagerRoute({ Component }) {
|
||||
|
||||
const user = userFromStorage();
|
||||
return isAuthd && (user?.role !== "default" || !multiUserMode) ? (
|
||||
<KeyboardShortcutWrapper>
|
||||
<UserMenu>
|
||||
<Component />
|
||||
</UserMenu>
|
||||
</KeyboardShortcutWrapper>
|
||||
) : (
|
||||
<Navigate to={paths.home()} />
|
||||
);
|
||||
@ -136,9 +143,11 @@ export default function PrivateRoute({ Component }) {
|
||||
}
|
||||
|
||||
return isAuthd ? (
|
||||
<KeyboardShortcutWrapper>
|
||||
<UserMenu>
|
||||
<Component />
|
||||
</UserMenu>
|
||||
</KeyboardShortcutWrapper>
|
||||
) : (
|
||||
<Navigate to={paths.login(true)} />
|
||||
);
|
||||
|
||||
@ -233,6 +233,7 @@ const TRANSLATIONS = {
|
||||
docs: "Docs",
|
||||
star: "Star on Github",
|
||||
},
|
||||
keyboardShortcuts: "Keyboard Shortcuts",
|
||||
},
|
||||
},
|
||||
|
||||
@ -981,6 +982,20 @@ const TRANSLATIONS = {
|
||||
theme: "Theme Preference",
|
||||
language: "Preferred language",
|
||||
},
|
||||
|
||||
"keyboard-shortcuts": {
|
||||
title: "Keyboard Shortcuts",
|
||||
shortcuts: {
|
||||
settings: "Open Settings",
|
||||
workspaceSettings: "Open Current Workspace Settings",
|
||||
home: "Go to Home",
|
||||
workspaces: "Manage Workspaces",
|
||||
apiKeys: "API Keys Settings",
|
||||
llmPreferences: "LLM Preferences",
|
||||
chatSettings: "Chat Settings",
|
||||
help: "Show keyboard shortcuts help",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default TRANSLATIONS;
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import paths from "@/utils/paths";
|
||||
import { ArrowCircleUpRight } from "@phosphor-icons/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { KEYBOARD_SHORTCUTS_HELP_EVENT } from "@/utils/keyboardShortcuts";
|
||||
|
||||
export default function Resources() {
|
||||
const { t } = useTranslation();
|
||||
const showKeyboardShortcuts = () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { detail: { show: true } })
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-theme-home-text uppercase text-sm font-semibold mb-4">
|
||||
@ -28,6 +35,13 @@ export default function Resources() {
|
||||
{t("main-page.resources.links.star")}
|
||||
<ArrowCircleUpRight weight="fill" size={16} />
|
||||
</a>
|
||||
<button
|
||||
onClick={showKeyboardShortcuts}
|
||||
className="text-theme-home-text text-sm flex items-center gap-x-2 hover:opacity-70"
|
||||
>
|
||||
{t("main-page.resources.keyboardShortcuts")}
|
||||
<ArrowCircleUpRight weight="fill" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
139
frontend/src/utils/keyboardShortcuts.js
Normal file
139
frontend/src/utils/keyboardShortcuts.js
Normal file
@ -0,0 +1,139 @@
|
||||
import paths from "./paths";
|
||||
import { useEffect, useState } from "react";
|
||||
import { userFromStorage } from "./request";
|
||||
|
||||
export const KEYBOARD_SHORTCUTS_HELP_EVENT = "keyboard-shortcuts-help";
|
||||
export const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
export const SHORTCUTS = {
|
||||
"⌘ + ,": {
|
||||
translationKey: "settings",
|
||||
action: () => {
|
||||
window.location.href = paths.settings.interface();
|
||||
},
|
||||
},
|
||||
"⌘ + H": {
|
||||
translationKey: "home",
|
||||
action: () => {
|
||||
window.location.href = paths.home();
|
||||
},
|
||||
},
|
||||
"⌘ + I": {
|
||||
translationKey: "workspaces",
|
||||
action: () => {
|
||||
window.location.href = paths.settings.workspaces();
|
||||
},
|
||||
},
|
||||
"⌘ + K": {
|
||||
translationKey: "apiKeys",
|
||||
action: () => {
|
||||
window.location.href = paths.settings.apiKeys();
|
||||
},
|
||||
},
|
||||
"⌘ + L": {
|
||||
translationKey: "llmPreferences",
|
||||
action: () => {
|
||||
window.location.href = paths.settings.llmPreference();
|
||||
},
|
||||
},
|
||||
"⌘ + Shift + C": {
|
||||
translationKey: "chatSettings",
|
||||
action: () => {
|
||||
window.location.href = paths.settings.chat();
|
||||
},
|
||||
},
|
||||
"⌘ + Shift + ?": {
|
||||
translationKey: "help",
|
||||
action: () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, {
|
||||
detail: { show: true },
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
F1: {
|
||||
translationKey: "help",
|
||||
action: () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, {
|
||||
detail: { show: true },
|
||||
})
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const LISTENERS = {};
|
||||
const modifier = isMac ? "meta" : "ctrl";
|
||||
for (const key in SHORTCUTS) {
|
||||
const listenerKey = key
|
||||
.replace("⌘", modifier)
|
||||
.replaceAll(" ", "")
|
||||
.toLowerCase();
|
||||
LISTENERS[listenerKey] = SHORTCUTS[key].action;
|
||||
}
|
||||
|
||||
// Convert keyboard event to shortcut key
|
||||
function getShortcutKey(event) {
|
||||
let key = "";
|
||||
if (event.metaKey || event.ctrlKey) key += modifier + "+";
|
||||
if (event.shiftKey) key += "shift+";
|
||||
if (event.altKey) key += "alt+";
|
||||
|
||||
// Handle special keys
|
||||
if (event.key === ",") key += ",";
|
||||
// Handle question mark or slash for help shortcut
|
||||
else if (event.key === "?" || event.key === "/") key += "?";
|
||||
else if (event.key === "Control")
|
||||
return ""; // Ignore Control key by itself
|
||||
else if (event.key === "Shift")
|
||||
return ""; // Ignore Shift key by itself
|
||||
else key += event.key.toLowerCase();
|
||||
return key;
|
||||
}
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
export function initKeyboardShortcuts() {
|
||||
function handleKeyDown(event) {
|
||||
const shortcutKey = getShortcutKey(event);
|
||||
if (!shortcutKey) return;
|
||||
|
||||
const action = LISTENERS[shortcutKey];
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
action();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}
|
||||
|
||||
function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
// If there is a user and the user is not an admin do not register the event listener
|
||||
// since some of the shortcuts are only available in multi-user mode as admin
|
||||
const user = userFromStorage();
|
||||
if (!!user && user?.role !== "admin") return;
|
||||
|
||||
function handleHelpEvent(e) {
|
||||
setShowHelp(e.detail.show);
|
||||
}
|
||||
window.addEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, handleHelpEvent);
|
||||
const cleanup = initKeyboardShortcuts();
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
window.removeEventListener(
|
||||
KEYBOARD_SHORTCUTS_HELP_EVENT,
|
||||
handleHelpEvent
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
return;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutWrapper({ children }) {
|
||||
useKeyboardShortcuts();
|
||||
return children;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user