tududi/frontend/components/Profile/tabs/KeyboardShortcutsTab.tsx
Chris 703f6fe506
Add custom keyboard shortcuts (#749)
* Add custom keyboard shortcuts

* fixup! Add custom keyboard shortcuts
2025-12-28 21:58:21 +02:00

307 lines
13 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CommandLineIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import {
KeyboardShortcut,
KeyboardShortcutsConfig,
ShortcutAction,
SHORTCUT_LABELS,
formatShortcutDisplay,
validateShortcuts,
getDefaultShortcuts,
getDefaultConfig,
shortcutToString,
} from '../../../utils/keyboardShortcutsService';
interface KeyboardShortcutsTabProps {
isActive: boolean;
config: KeyboardShortcutsConfig | null | undefined;
onChange: (config: KeyboardShortcutsConfig) => void;
}
const SHORTCUT_ACTIONS: ShortcutAction[] = [
'inbox',
'task',
'project',
'note',
'area',
'tag',
];
const KeyboardShortcutsTab: React.FC<KeyboardShortcutsTabProps> = ({
isActive,
config,
onChange,
}) => {
const { t } = useTranslation();
const [editingAction, setEditingAction] = useState<ShortcutAction | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [tempShortcut, setTempShortcut] = useState<KeyboardShortcut | null>(null);
// Ensure we always have a valid config
const activeConfig = config || getDefaultConfig();
const shortcuts = activeConfig.shortcuts || getDefaultShortcuts();
// Validation
const validation = validateShortcuts(shortcuts);
const handleEditClick = (action: ShortcutAction) => {
const current = shortcuts.find(s => s.action === action);
setEditingAction(action);
setTempShortcut(current || null);
setIsRecording(false);
};
const handleCancelEdit = () => {
setEditingAction(null);
setTempShortcut(null);
setIsRecording(false);
};
const handleSaveEdit = () => {
if (!editingAction || !tempShortcut) return;
const newShortcuts = shortcuts.map(s =>
s.action === editingAction ? tempShortcut : s
);
onChange({
...activeConfig,
shortcuts: newShortcuts,
});
setEditingAction(null);
setTempShortcut(null);
setIsRecording(false);
};
const handleStartRecording = () => {
setIsRecording(true);
};
// Handle keyboard recording
useEffect(() => {
if (!isRecording || !editingAction) return;
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
// Ignore if only modifier keys are pressed
const key = event.key.toLowerCase();
if (['control', 'alt', 'shift', 'meta'].includes(key)) {
return;
}
// Require at least one modifier
if (!event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) {
return;
}
const newShortcut: KeyboardShortcut = {
action: editingAction,
key: key.length === 1 ? key : key,
modifiers: {
ctrl: event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
},
};
setTempShortcut(newShortcut);
setIsRecording(false);
};
document.addEventListener('keydown', handleKeyDown, true);
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [isRecording, editingAction]);
const handleResetToDefaults = () => {
onChange(getDefaultConfig());
};
const handleToggleEnabled = () => {
onChange({
...activeConfig,
enabled: !activeConfig.enabled,
});
};
const getShortcutForAction = (action: ShortcutAction): KeyboardShortcut | undefined => {
return shortcuts.find(s => s.action === action);
};
// Check if a shortcut would create a duplicate
const wouldCreateDuplicate = (newShortcut: KeyboardShortcut): string | null => {
const newKey = shortcutToString(newShortcut);
for (const existing of shortcuts) {
if (existing.action !== newShortcut.action && shortcutToString(existing) === newKey) {
return existing.action;
}
}
return null;
};
if (!isActive) return null;
return (
<div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
<CommandLineIcon className="w-6 h-6 mr-3 text-purple-500" />
{t('profile.keyboardShortcuts', 'Keyboard Shortcuts')}
</h3>
{/* Enable/Disable Toggle */}
<div className="flex items-center justify-between py-4 border-b border-gray-200 dark:border-gray-700 mb-6">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('profile.shortcuts.enableShortcuts', 'Enable Keyboard Shortcuts')}
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t(
'profile.shortcuts.enableDescription',
'Turn keyboard shortcuts on or off globally.'
)}
</p>
</div>
<div
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
activeConfig.enabled
? 'bg-blue-500'
: 'bg-gray-300 dark:bg-gray-600'
}`}
onClick={handleToggleEnabled}
>
<span
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
activeConfig.enabled ? 'translate-x-6' : 'translate-x-0'
}`}
></span>
</div>
</div>
{/* Validation Warnings */}
{!validation.valid && (
<div className="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{t('profile.shortcuts.duplicateWarning', 'Duplicate shortcuts detected:')}
</p>
<ul className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 list-disc list-inside">
{validation.duplicates.map((dup, idx) => (
<li key={idx}>{dup}</li>
))}
</ul>
</div>
)}
{/* Shortcuts List */}
<div className="space-y-3 mb-6">
{SHORTCUT_ACTIONS.map((action) => {
const shortcut = getShortcutForAction(action);
const isEditing = editingAction === action;
const label = SHORTCUT_LABELS[action];
return (
<div
key={action}
className="flex items-center justify-between py-3 px-4 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<div className="flex-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t(label.labelKey, label.defaultLabel)}
</span>
</div>
{isEditing ? (
<div className="flex items-center space-x-2">
{isRecording ? (
<div className="px-3 py-1.5 bg-blue-100 dark:bg-blue-900/30 border-2 border-blue-400 dark:border-blue-600 rounded text-blue-700 dark:text-blue-300 text-sm font-mono animate-pulse">
{t('profile.shortcuts.pressKeys', 'Press keys...')}
</div>
) : tempShortcut ? (
<div className="flex items-center space-x-2">
<kbd className="px-3 py-1.5 text-sm font-mono bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-sm text-gray-700 dark:text-gray-300">
{formatShortcutDisplay(tempShortcut)}
</kbd>
{wouldCreateDuplicate(tempShortcut) && (
<span className="text-xs text-yellow-600 dark:text-yellow-400">
{t('profile.shortcuts.duplicateWith', 'Conflicts with {{action}}', {
action: wouldCreateDuplicate(tempShortcut),
})}
</span>
)}
</div>
) : null}
<button
type="button"
onClick={handleStartRecording}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
{t('profile.shortcuts.record', 'Record')}
</button>
<button
type="button"
onClick={handleSaveEdit}
disabled={!tempShortcut}
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('common.save', 'Save')}
</button>
<button
type="button"
onClick={handleCancelEdit}
className="px-3 py-1.5 text-xs bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
</div>
) : (
<div className="flex items-center space-x-3">
{shortcut && (
<kbd className="px-3 py-1.5 text-sm font-mono bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded shadow-sm text-gray-700 dark:text-gray-300">
{formatShortcutDisplay(shortcut)}
</kbd>
)}
<button
type="button"
onClick={() => handleEditClick(action)}
className="px-3 py-1.5 text-xs bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors"
>
{t('common.edit', 'Edit')}
</button>
</div>
)}
</div>
);
})}
</div>
{/* Reset to Defaults */}
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={handleResetToDefaults}
className="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{t('profile.shortcuts.resetToDefaults', 'Reset to Defaults')}
</button>
</div>
{/* Info Box */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-xs text-blue-700 dark:text-blue-300">
{t(
'profile.shortcuts.info',
'Keyboard shortcuts help you navigate quickly. Changes are saved when you click "Save Changes" at the bottom of the page. Shortcuts are disabled when typing in text fields.'
)}
</p>
</div>
</div>
);
};
export default KeyboardShortcutsTab;