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 { LogoProvider } from "./LogoContext";
|
||||||
import { FullScreenLoader } from "./components/Preloader";
|
import { FullScreenLoader } from "./components/Preloader";
|
||||||
import { ThemeProvider } from "./ThemeContext";
|
import { ThemeProvider } from "./ThemeContext";
|
||||||
|
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
|
||||||
|
|
||||||
const Main = lazy(() => import("@/pages/Main"));
|
const Main = lazy(() => import("@/pages/Main"));
|
||||||
const InvitePage = lazy(() => import("@/pages/Invite"));
|
const InvitePage = lazy(() => import("@/pages/Invite"));
|
||||||
@ -269,6 +270,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
<KeyboardShortcutsHelp />
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</PfpProvider>
|
</PfpProvider>
|
||||||
</LogoProvider>
|
</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 { userFromStorage } from "@/utils/request";
|
||||||
import System from "@/models/system";
|
import System from "@/models/system";
|
||||||
import UserMenu from "../UserMenu";
|
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.
|
// 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.
|
// When in single user mode we just bypass any authchecks.
|
||||||
@ -95,11 +96,15 @@ export function AdminRoute({ Component, hideUserMenu = false }) {
|
|||||||
const user = userFromStorage();
|
const user = userFromStorage();
|
||||||
return isAuthd && (user?.role === "admin" || !multiUserMode) ? (
|
return isAuthd && (user?.role === "admin" || !multiUserMode) ? (
|
||||||
hideUserMenu ? (
|
hideUserMenu ? (
|
||||||
<Component />
|
<KeyboardShortcutWrapper>
|
||||||
) : (
|
|
||||||
<UserMenu>
|
|
||||||
<Component />
|
<Component />
|
||||||
</UserMenu>
|
</KeyboardShortcutWrapper>
|
||||||
|
) : (
|
||||||
|
<KeyboardShortcutWrapper>
|
||||||
|
<UserMenu>
|
||||||
|
<Component />
|
||||||
|
</UserMenu>
|
||||||
|
</KeyboardShortcutWrapper>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Navigate to={paths.home()} />
|
<Navigate to={paths.home()} />
|
||||||
@ -119,9 +124,11 @@ export function ManagerRoute({ Component }) {
|
|||||||
|
|
||||||
const user = userFromStorage();
|
const user = userFromStorage();
|
||||||
return isAuthd && (user?.role !== "default" || !multiUserMode) ? (
|
return isAuthd && (user?.role !== "default" || !multiUserMode) ? (
|
||||||
<UserMenu>
|
<KeyboardShortcutWrapper>
|
||||||
<Component />
|
<UserMenu>
|
||||||
</UserMenu>
|
<Component />
|
||||||
|
</UserMenu>
|
||||||
|
</KeyboardShortcutWrapper>
|
||||||
) : (
|
) : (
|
||||||
<Navigate to={paths.home()} />
|
<Navigate to={paths.home()} />
|
||||||
);
|
);
|
||||||
@ -136,9 +143,11 @@ export default function PrivateRoute({ Component }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return isAuthd ? (
|
return isAuthd ? (
|
||||||
<UserMenu>
|
<KeyboardShortcutWrapper>
|
||||||
<Component />
|
<UserMenu>
|
||||||
</UserMenu>
|
<Component />
|
||||||
|
</UserMenu>
|
||||||
|
</KeyboardShortcutWrapper>
|
||||||
) : (
|
) : (
|
||||||
<Navigate to={paths.login(true)} />
|
<Navigate to={paths.login(true)} />
|
||||||
);
|
);
|
||||||
|
|||||||
@ -233,6 +233,7 @@ const TRANSLATIONS = {
|
|||||||
docs: "Docs",
|
docs: "Docs",
|
||||||
star: "Star on Github",
|
star: "Star on Github",
|
||||||
},
|
},
|
||||||
|
keyboardShortcuts: "Keyboard Shortcuts",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -981,6 +982,20 @@ const TRANSLATIONS = {
|
|||||||
theme: "Theme Preference",
|
theme: "Theme Preference",
|
||||||
language: "Preferred language",
|
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;
|
export default TRANSLATIONS;
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
import paths from "@/utils/paths";
|
import paths from "@/utils/paths";
|
||||||
import { ArrowCircleUpRight } from "@phosphor-icons/react";
|
import { ArrowCircleUpRight } from "@phosphor-icons/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { KEYBOARD_SHORTCUTS_HELP_EVENT } from "@/utils/keyboardShortcuts";
|
||||||
|
|
||||||
export default function Resources() {
|
export default function Resources() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showKeyboardShortcuts = () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { detail: { show: true } })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-theme-home-text uppercase text-sm font-semibold mb-4">
|
<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")}
|
{t("main-page.resources.links.star")}
|
||||||
<ArrowCircleUpRight weight="fill" size={16} />
|
<ArrowCircleUpRight weight="fill" size={16} />
|
||||||
</a>
|
</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>
|
||||||
</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