feat: add keyboard shortcuts help modal (press ? to open)
This commit is contained in:
14
src/App.tsx
14
src/App.tsx
@@ -5,6 +5,7 @@ import { Sidebar } from './components/Sidebar';
|
|||||||
import { Chat } from './components/Chat';
|
import { Chat } from './components/Chat';
|
||||||
import { LoginScreen } from './components/LoginScreen';
|
import { LoginScreen } from './components/LoginScreen';
|
||||||
import { ConnectionBanner } from './components/ConnectionBanner';
|
import { ConnectionBanner } from './components/ConnectionBanner';
|
||||||
|
import { KeyboardShortcuts } from './components/KeyboardShortcuts';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
@@ -13,13 +14,21 @@ export default function App() {
|
|||||||
authenticated, login, logout, connectError, isConnecting,
|
authenticated, login, logout, connectError, isConnecting,
|
||||||
} = useGateway();
|
} = useGateway();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
|
|
||||||
// Close sidebar on Escape key
|
// Close sidebar on Escape key, open shortcuts on ?
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && sidebarOpen) {
|
if (e.key === 'Escape' && sidebarOpen) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
}, [sidebarOpen]);
|
// Open shortcuts help with ? (only when not typing in an input)
|
||||||
|
if (e.key === '?' && !shortcutsOpen) {
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setShortcutsOpen(true);
|
||||||
|
}
|
||||||
|
}, [sidebarOpen, shortcutsOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
@@ -54,6 +63,7 @@ export default function App() {
|
|||||||
<ConnectionBanner status={status} />
|
<ConnectionBanner status={status} />
|
||||||
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
|
<Chat messages={messages} isGenerating={isGenerating} status={status} onSend={sendMessage} onAbort={abort} />
|
||||||
</div>
|
</div>
|
||||||
|
<KeyboardShortcuts open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
118
src/components/KeyboardShortcuts.tsx
Normal file
118
src/components/KeyboardShortcuts.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { X, Keyboard } from 'lucide-react';
|
||||||
|
import { useT } from '../hooks/useLocale';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kbd({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<kbd className="inline-flex items-center justify-center min-w-[1.75rem] h-7 px-2 rounded-lg border border-white/10 bg-zinc-800/80 text-xs font-mono text-zinc-300 shadow-[0_1px_0_0_rgba(255,255,255,0.05)]">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortcutRow({ keys, label }: { keys: React.ReactNode; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<span className="text-sm text-zinc-400">{label}</span>
|
||||||
|
<div className="flex items-center gap-1.5">{keys}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-zinc-500 font-semibold mt-4 mb-1 first:mt-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
||||||
|
const mod = isMac ? '⌘' : 'Ctrl';
|
||||||
|
|
||||||
|
export function KeyboardShortcuts({ open, onClose }: Props) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div
|
||||||
|
className="relative w-full max-w-md mx-4 rounded-3xl border border-white/8 bg-[#1e1e24]/95 backdrop-blur-xl shadow-2xl animate-fade-in"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/8">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Keyboard size={18} className="text-cyan-300/70" />
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-200">{t('shortcuts.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8 rounded-xl flex items-center justify-center text-zinc-500 hover:text-zinc-300 hover:bg-white/5 transition-colors"
|
||||||
|
aria-label={t('shortcuts.close')}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-6 py-4 divide-y divide-white/5">
|
||||||
|
<div className="pb-3">
|
||||||
|
<SectionTitle>{t('shortcuts.chatSection')}</SectionTitle>
|
||||||
|
<ShortcutRow
|
||||||
|
keys={<Kbd>Enter</Kbd>}
|
||||||
|
label={t('shortcuts.send')}
|
||||||
|
/>
|
||||||
|
<ShortcutRow
|
||||||
|
keys={<><Kbd>Shift</Kbd><span className="text-zinc-600">+</span><Kbd>Enter</Kbd></>}
|
||||||
|
label={t('shortcuts.newline')}
|
||||||
|
/>
|
||||||
|
<ShortcutRow
|
||||||
|
keys={<Kbd>Esc</Kbd>}
|
||||||
|
label={t('shortcuts.stop')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-3">
|
||||||
|
<SectionTitle>{t('shortcuts.navigationSection')}</SectionTitle>
|
||||||
|
<ShortcutRow
|
||||||
|
keys={<><Kbd>{mod}</Kbd><span className="text-zinc-600">+</span><Kbd>K</Kbd></>}
|
||||||
|
label={t('shortcuts.search')}
|
||||||
|
/>
|
||||||
|
<ShortcutRow
|
||||||
|
keys={<Kbd>Esc</Kbd>}
|
||||||
|
label={t('shortcuts.closeSidebar')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3">
|
||||||
|
<SectionTitle>{t('shortcuts.generalSection')}</SectionTitle>
|
||||||
|
<ShortcutRow
|
||||||
|
keys={<Kbd>?</Kbd>}
|
||||||
|
label={t('shortcuts.help')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -62,6 +62,19 @@ const en = {
|
|||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
'time.yesterday': 'Yesterday',
|
'time.yesterday': 'Yesterday',
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
'shortcuts.title': 'Keyboard Shortcuts',
|
||||||
|
'shortcuts.send': 'Send message',
|
||||||
|
'shortcuts.newline': 'New line',
|
||||||
|
'shortcuts.search': 'Search sessions',
|
||||||
|
'shortcuts.closeSidebar': 'Close sidebar / search',
|
||||||
|
'shortcuts.stop': 'Stop generation',
|
||||||
|
'shortcuts.help': 'Show shortcuts',
|
||||||
|
'shortcuts.close': 'Close',
|
||||||
|
'shortcuts.chatSection': 'Chat',
|
||||||
|
'shortcuts.navigationSection': 'Navigation',
|
||||||
|
'shortcuts.generalSection': 'General',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const fr: Record<keyof typeof en, string> = {
|
const fr: Record<keyof typeof en, string> = {
|
||||||
@@ -110,6 +123,18 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'message.copied': 'Copié !',
|
'message.copied': 'Copié !',
|
||||||
|
|
||||||
'time.yesterday': 'Hier',
|
'time.yesterday': 'Hier',
|
||||||
|
|
||||||
|
'shortcuts.title': 'Raccourcis clavier',
|
||||||
|
'shortcuts.send': 'Envoyer le message',
|
||||||
|
'shortcuts.newline': 'Nouvelle ligne',
|
||||||
|
'shortcuts.search': 'Rechercher des sessions',
|
||||||
|
'shortcuts.closeSidebar': 'Fermer la barre / recherche',
|
||||||
|
'shortcuts.stop': 'Arrêter la génération',
|
||||||
|
'shortcuts.help': 'Afficher les raccourcis',
|
||||||
|
'shortcuts.close': 'Fermer',
|
||||||
|
'shortcuts.chatSection': 'Chat',
|
||||||
|
'shortcuts.navigationSection': 'Navigation',
|
||||||
|
'shortcuts.generalSection': 'Général',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TranslationKey = keyof typeof en;
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|||||||
Reference in New Issue
Block a user