Add custom keyboard shortcuts (#749)
* Add custom keyboard shortcuts * fixup! Add custom keyboard shortcuts
This commit is contained in:
parent
8c839199e1
commit
703f6fe506
12 changed files with 718 additions and 56 deletions
19
backend/migrations/20260201000002-add-keyboard-shortcuts.js
Normal file
19
backend/migrations/20260201000002-add-keyboard-shortcuts.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Migration to add keyboard_shortcuts JSON column to users table.
|
||||
* This stores user-configurable keyboard shortcuts for quick actions.
|
||||
*/
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.safeAddColumn('users', 'keyboard_shortcuts', {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await queryInterface.safeRemoveColumn('users', 'keyboard_shortcuts');
|
||||
},
|
||||
};
|
||||
|
|
@ -212,6 +212,13 @@ module.exports = (sequelize) => {
|
|||
},
|
||||
},
|
||||
},
|
||||
keyboard_shortcuts: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
comment:
|
||||
'User-configurable keyboard shortcuts for quick actions',
|
||||
},
|
||||
email_verified: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ router.get('/profile', async (req, res) => {
|
|||
'productivity_assistant_enabled',
|
||||
'next_task_suggestion_enabled',
|
||||
'notification_preferences',
|
||||
'keyboard_shortcuts',
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -188,6 +189,7 @@ router.patch('/profile', async (req, res) => {
|
|||
pomodoro_enabled,
|
||||
ui_settings,
|
||||
notification_preferences,
|
||||
keyboard_shortcuts,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
} = req.body;
|
||||
|
|
@ -227,6 +229,8 @@ router.patch('/profile', async (req, res) => {
|
|||
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
|
||||
if (notification_preferences !== undefined)
|
||||
allowedUpdates.notification_preferences = notification_preferences;
|
||||
if (keyboard_shortcuts !== undefined)
|
||||
allowedUpdates.keyboard_shortcuts = keyboard_shortcuts;
|
||||
|
||||
// Validate first_day_of_week if provided
|
||||
if (first_day_of_week !== undefined) {
|
||||
|
|
@ -292,6 +296,7 @@ router.patch('/profile', async (req, res) => {
|
|||
'next_task_suggestion_enabled',
|
||||
'pomodoro_enabled',
|
||||
'notification_preferences',
|
||||
'keyboard_shortcuts',
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import {
|
|||
} from './utils/projectsService';
|
||||
import { isAuthError } from './utils/authUtils';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { getApiPath } from './config/paths';
|
||||
import { KeyboardShortcutsConfig } from './utils/keyboardShortcutsService';
|
||||
|
||||
interface LayoutProps {
|
||||
currentUser: User;
|
||||
|
|
@ -58,6 +60,25 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
const [keyboardShortcuts, setKeyboardShortcuts] = useState<KeyboardShortcutsConfig | null>(null);
|
||||
|
||||
// Fetch keyboard shortcuts from profile
|
||||
useEffect(() => {
|
||||
const fetchKeyboardShortcuts = async () => {
|
||||
try {
|
||||
const response = await fetch(getApiPath('profile'));
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.keyboard_shortcuts) {
|
||||
setKeyboardShortcuts(data.keyboard_shortcuts);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching keyboard shortcuts:', error);
|
||||
}
|
||||
};
|
||||
fetchKeyboardShortcuts();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
notesStore: { notes, isLoading: isNotesLoading, isError: isNotesError },
|
||||
|
|
@ -354,6 +375,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
notes={notes}
|
||||
areas={areas}
|
||||
tags={tags}
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
/>
|
||||
<div
|
||||
className={`flex-1 flex items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
|
||||
|
|
@ -392,6 +414,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
notes={notes}
|
||||
areas={areas}
|
||||
tags={tags}
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
/>
|
||||
<div
|
||||
className={`flex-1 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800 transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
|
||||
|
|
@ -430,6 +453,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
notes={notes}
|
||||
areas={areas}
|
||||
tags={tags}
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
/>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
KeyIcon,
|
||||
CheckIcon,
|
||||
BellIcon,
|
||||
CommandLineIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import TelegramIcon from '../Shared/Icons/TelegramIcon';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
|
@ -42,6 +43,8 @@ import ProductivityTab from './tabs/ProductivityTab';
|
|||
import TelegramTab from './tabs/TelegramTab';
|
||||
import AiTab from './tabs/AiTab';
|
||||
import NotificationsTab from './tabs/NotificationsTab';
|
||||
import KeyboardShortcutsTab from './tabs/KeyboardShortcutsTab';
|
||||
import { getDefaultConfig } from '../../utils/keyboardShortcutsService';
|
||||
import type {
|
||||
ProfileSettingsProps,
|
||||
Profile,
|
||||
|
|
@ -84,6 +87,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||
'telegram',
|
||||
'ai',
|
||||
'notifications',
|
||||
'keyboard-shortcuts',
|
||||
];
|
||||
return section && validTabs.includes(section) ? section : 'general';
|
||||
}, [location.search]);
|
||||
|
|
@ -116,6 +120,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||
next_task_suggestion_enabled: true,
|
||||
pomodoro_enabled: true,
|
||||
notification_preferences: null,
|
||||
keyboard_shortcuts: null,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
|
|
@ -512,6 +517,8 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||
: true,
|
||||
notification_preferences:
|
||||
data.notification_preferences || null,
|
||||
keyboard_shortcuts:
|
||||
data.keyboard_shortcuts || getDefaultConfig(),
|
||||
});
|
||||
|
||||
if (data.telegram_bot_token) {
|
||||
|
|
@ -1115,6 +1122,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||
name: t('profile.tabs.ai', 'AI Features'),
|
||||
icon: <LightBulbIcon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: 'keyboard-shortcuts',
|
||||
name: t('profile.tabs.keyboardShortcuts', 'Shortcuts'),
|
||||
icon: <CommandLineIcon className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -1295,6 +1307,17 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
|
|||
}
|
||||
/>
|
||||
|
||||
<KeyboardShortcutsTab
|
||||
isActive={activeTab === 'keyboard-shortcuts'}
|
||||
config={formData.keyboard_shortcuts}
|
||||
onChange={(config) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
keyboard_shortcuts: config,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
|||
307
frontend/components/Profile/tabs/KeyboardShortcutsTab.tsx
Normal file
307
frontend/components/Profile/tabs/KeyboardShortcutsTab.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
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;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import type { KeyboardShortcutsConfig } from '../../utils/keyboardShortcutsService';
|
||||
|
||||
export interface ProfileSettingsProps {
|
||||
currentUser: { uid: string; email: string };
|
||||
isDarkMode?: boolean;
|
||||
|
|
@ -58,6 +60,7 @@ export interface Profile {
|
|||
next_task_suggestion_enabled: boolean;
|
||||
pomodoro_enabled: boolean;
|
||||
notification_preferences?: NotificationPreferences | null;
|
||||
keyboard_shortcuts?: KeyboardShortcutsConfig | null;
|
||||
}
|
||||
|
||||
export interface TelegramBotInfo {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import SidebarProjects from './Sidebar/SidebarProjects';
|
|||
import SidebarTags from './Sidebar/SidebarTags';
|
||||
import SidebarViews from './Sidebar/SidebarViews';
|
||||
import { getFeatureFlags, FeatureFlags } from '../utils/featureFlags';
|
||||
import { KeyboardShortcutsConfig } from '../utils/keyboardShortcutsService';
|
||||
|
||||
interface SidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
|
|
@ -28,6 +29,7 @@ interface SidebarProps {
|
|||
notes: Note[];
|
||||
areas: Area[];
|
||||
tags: Tag[];
|
||||
keyboardShortcuts?: KeyboardShortcutsConfig | null;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
|
|
@ -45,6 +47,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
notes,
|
||||
areas,
|
||||
tags,
|
||||
keyboardShortcuts,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
|
@ -149,6 +152,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||
openNoteModal={openNoteModal}
|
||||
openAreaModal={openAreaModal}
|
||||
openTagModal={openTagModal}
|
||||
keyboardShortcuts={keyboardShortcuts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
SunIcon,
|
||||
|
|
@ -17,6 +17,14 @@ import { Note } from '../../entities/Note';
|
|||
import { Area } from '../../entities/Area';
|
||||
import { useTelegramStatus } from '../../contexts/TelegramStatusContext';
|
||||
import { getApiPath } from '../../config/paths';
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts';
|
||||
import {
|
||||
KeyboardShortcutsConfig,
|
||||
ShortcutAction,
|
||||
formatShortcutDisplay,
|
||||
getDefaultShortcuts,
|
||||
getShortcutByAction,
|
||||
} from '../../utils/keyboardShortcutsService';
|
||||
|
||||
interface SidebarFooterProps {
|
||||
currentUser: { email: string };
|
||||
|
|
@ -31,6 +39,7 @@ interface SidebarFooterProps {
|
|||
openNoteModal: (note: Note | null) => void;
|
||||
openAreaModal: (area: Area | null) => void;
|
||||
openTagModal: (tag: any | null) => void;
|
||||
keyboardShortcuts?: KeyboardShortcutsConfig | null;
|
||||
}
|
||||
|
||||
const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
||||
|
|
@ -43,6 +52,7 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
openNoteModal,
|
||||
openAreaModal,
|
||||
openTagModal,
|
||||
keyboardShortcuts,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
|
@ -51,6 +61,13 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
const [version, setVersion] = useState<string>('v0.86');
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Get shortcuts config, using defaults if not provided
|
||||
const shortcuts = useMemo(() => {
|
||||
return keyboardShortcuts?.shortcuts || getDefaultShortcuts();
|
||||
}, [keyboardShortcuts]);
|
||||
|
||||
const shortcutsEnabled = keyboardShortcuts?.enabled ?? true;
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
};
|
||||
|
|
@ -89,49 +106,6 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
});
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check for Ctrl + Shift key combinations only (not Cmd/Meta key)
|
||||
if (event.ctrlKey && event.shiftKey && !event.metaKey) {
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'i':
|
||||
event.preventDefault();
|
||||
handleDropdownSelect('Inbox');
|
||||
break;
|
||||
case 't':
|
||||
event.preventDefault();
|
||||
handleDropdownSelect('Task');
|
||||
break;
|
||||
case 'p':
|
||||
event.preventDefault();
|
||||
handleDropdownSelect('Project');
|
||||
break;
|
||||
case 'n':
|
||||
event.preventDefault();
|
||||
handleDropdownSelect('Note');
|
||||
break;
|
||||
case 'a':
|
||||
event.preventDefault();
|
||||
handleDropdownSelect('Area');
|
||||
break;
|
||||
case 'g':
|
||||
event.preventDefault();
|
||||
handleDropdownSelect('Tag');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDropdownSelect = (type: string) => {
|
||||
switch (type) {
|
||||
case 'Inbox':
|
||||
|
|
@ -161,44 +135,65 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Use the keyboard shortcuts hook
|
||||
useKeyboardShortcuts(
|
||||
shortcuts,
|
||||
{
|
||||
inbox: () => handleDropdownSelect('Inbox'),
|
||||
task: () => handleDropdownSelect('Task'),
|
||||
project: () => handleDropdownSelect('Project'),
|
||||
note: () => handleDropdownSelect('Note'),
|
||||
area: () => handleDropdownSelect('Area'),
|
||||
tag: () => handleDropdownSelect('Tag'),
|
||||
},
|
||||
shortcutsEnabled
|
||||
);
|
||||
|
||||
// Helper to get the display string for a shortcut action
|
||||
const getShortcutDisplay = (action: ShortcutAction): string => {
|
||||
const shortcut = getShortcutByAction(shortcuts, action);
|
||||
return shortcut ? formatShortcutDisplay(shortcut) : '';
|
||||
};
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
label: 'Inbox',
|
||||
translationKey: 'dropdown.inbox',
|
||||
icon: <InboxIcon className="h-5 w-5 mr-2" />,
|
||||
shortcut: 'Ctrl+Shift+I',
|
||||
action: 'inbox' as ShortcutAction,
|
||||
},
|
||||
{
|
||||
label: 'Task',
|
||||
translationKey: 'dropdown.task',
|
||||
icon: <CheckIcon className="h-5 w-5 mr-2" />,
|
||||
shortcut: 'Ctrl+Shift+T',
|
||||
action: 'task' as ShortcutAction,
|
||||
},
|
||||
{
|
||||
label: 'Project',
|
||||
translationKey: 'dropdown.project',
|
||||
icon: <FolderIcon className="h-5 w-5 mr-2" />,
|
||||
shortcut: 'Ctrl+Shift+P',
|
||||
action: 'project' as ShortcutAction,
|
||||
},
|
||||
{
|
||||
label: 'Note',
|
||||
translationKey: 'dropdown.note',
|
||||
icon: <BookOpenIcon className="h-5 w-5 mr-2" />,
|
||||
shortcut: 'Ctrl+Shift+N',
|
||||
action: 'note' as ShortcutAction,
|
||||
},
|
||||
{
|
||||
label: 'Area',
|
||||
translationKey: 'dropdown.area',
|
||||
icon: <Squares2X2Icon className="h-5 w-5 mr-2" />,
|
||||
shortcut: 'Ctrl+Shift+A',
|
||||
action: 'area' as ShortcutAction,
|
||||
},
|
||||
{
|
||||
label: 'Tag',
|
||||
translationKey: 'dropdown.tag',
|
||||
icon: <TagIcon className="h-5 w-5 mr-2" />,
|
||||
shortcut: 'Ctrl+Shift+G',
|
||||
action: 'tag' as ShortcutAction,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-auto p-3">
|
||||
{/* Version Display */}
|
||||
|
|
@ -237,7 +232,7 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
label,
|
||||
translationKey,
|
||||
icon,
|
||||
shortcut,
|
||||
action,
|
||||
}) => (
|
||||
<button
|
||||
key={label}
|
||||
|
|
@ -256,15 +251,12 @@ const SidebarFooter: React.FC<SidebarFooterProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<span
|
||||
className="bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded text-xs font-mono text-gray-500 dark:text-gray-400 opacity-60"
|
||||
className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono text-gray-500 dark:text-gray-400"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
}}
|
||||
>
|
||||
^ + Shift +{' '}
|
||||
{shortcut
|
||||
.split('+')
|
||||
.pop()}
|
||||
{getShortcutDisplay(action)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
75
frontend/hooks/useKeyboardShortcuts.ts
Normal file
75
frontend/hooks/useKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
KeyboardShortcut,
|
||||
ShortcutAction,
|
||||
matchesShortcut,
|
||||
isInputElement,
|
||||
getDefaultShortcuts,
|
||||
} from '../utils/keyboardShortcutsService';
|
||||
|
||||
type ShortcutHandlers = Partial<Record<ShortcutAction, () => void>>;
|
||||
|
||||
/**
|
||||
* Hook to register and handle keyboard shortcuts
|
||||
*
|
||||
* @param shortcuts - Array of keyboard shortcuts to listen for
|
||||
* @param handlers - Object mapping actions to handler functions
|
||||
* @param enabled - Whether shortcuts are currently enabled
|
||||
*
|
||||
* @example
|
||||
* useKeyboardShortcuts(
|
||||
* userShortcuts,
|
||||
* {
|
||||
* inbox: () => navigate('/inbox'),
|
||||
* task: () => openTaskModal(),
|
||||
* },
|
||||
* true
|
||||
* );
|
||||
*/
|
||||
export const useKeyboardShortcuts = (
|
||||
shortcuts: KeyboardShortcut[] | undefined,
|
||||
handlers: ShortcutHandlers,
|
||||
enabled: boolean = true
|
||||
) => {
|
||||
// Use defaults if no shortcuts provided
|
||||
const activeShortcuts = useMemo(
|
||||
() => shortcuts || getDefaultShortcuts(),
|
||||
[shortcuts]
|
||||
);
|
||||
|
||||
// Memoize the handler to prevent unnecessary re-renders
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled
|
||||
if (!enabled) return;
|
||||
|
||||
// Skip if user is typing in an input field
|
||||
if (isInputElement(event.target)) return;
|
||||
|
||||
// Check each shortcut for a match
|
||||
for (const shortcut of activeShortcuts) {
|
||||
if (matchesShortcut(event, shortcut)) {
|
||||
const handler = handlers[shortcut.action];
|
||||
if (handler) {
|
||||
event.preventDefault();
|
||||
handler();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeShortcuts, handlers, enabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown, enabled]);
|
||||
};
|
||||
|
||||
export default useKeyboardShortcuts;
|
||||
184
frontend/utils/keyboardShortcutsService.ts
Normal file
184
frontend/utils/keyboardShortcutsService.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// Keyboard Shortcuts Service
|
||||
// Provides types, defaults, and utility functions for keyboard shortcuts
|
||||
|
||||
export type ShortcutAction = 'inbox' | 'task' | 'project' | 'note' | 'area' | 'tag';
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
action: ShortcutAction;
|
||||
key: string;
|
||||
modifiers: {
|
||||
alt: boolean;
|
||||
shift: boolean;
|
||||
ctrl: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutsConfig {
|
||||
shortcuts: KeyboardShortcut[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Default shortcuts using Alt+Shift to avoid browser conflicts
|
||||
export const DEFAULT_SHORTCUTS: KeyboardShortcut[] = [
|
||||
{ action: 'inbox', key: 'i', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
|
||||
{ action: 'task', key: 't', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
|
||||
{ action: 'project', key: 'p', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
|
||||
{ action: 'note', key: 'n', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
|
||||
{ action: 'area', key: 'a', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
|
||||
{ action: 'tag', key: 'g', modifiers: { alt: true, shift: true, ctrl: false, meta: false } },
|
||||
];
|
||||
|
||||
export const SHORTCUT_LABELS: Record<ShortcutAction, { labelKey: string; defaultLabel: string }> = {
|
||||
inbox: { labelKey: 'profile.shortcuts.actions.inbox', defaultLabel: 'Create new Inbox item' },
|
||||
task: { labelKey: 'profile.shortcuts.actions.task', defaultLabel: 'Create new Task' },
|
||||
project: { labelKey: 'profile.shortcuts.actions.project', defaultLabel: 'Create new Project' },
|
||||
note: { labelKey: 'profile.shortcuts.actions.note', defaultLabel: 'Create new Note' },
|
||||
area: { labelKey: 'profile.shortcuts.actions.area', defaultLabel: 'Create new Area' },
|
||||
tag: { labelKey: 'profile.shortcuts.actions.tag', defaultLabel: 'Create new Tag' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects if the user is on a Mac platform
|
||||
*/
|
||||
export const isMac = (): boolean => {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a shortcut for display
|
||||
* Always uses full text format: "Ctrl + Shift + T"
|
||||
*/
|
||||
export const formatShortcutDisplay = (shortcut: KeyboardShortcut): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (shortcut.modifiers.ctrl) {
|
||||
parts.push('Ctrl');
|
||||
}
|
||||
if (shortcut.modifiers.alt) {
|
||||
parts.push('Alt');
|
||||
}
|
||||
if (shortcut.modifiers.shift) {
|
||||
parts.push('Shift');
|
||||
}
|
||||
if (shortcut.modifiers.meta) {
|
||||
parts.push(isMac() ? 'Cmd' : 'Win');
|
||||
}
|
||||
|
||||
parts.push(shortcut.key.toUpperCase());
|
||||
|
||||
return parts.join(' + ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a keyboard event into a normalized format
|
||||
*/
|
||||
export const parseKeyboardEvent = (event: KeyboardEvent) => {
|
||||
return {
|
||||
key: event.key.toLowerCase(),
|
||||
modifiers: {
|
||||
alt: event.altKey,
|
||||
shift: event.shiftKey,
|
||||
ctrl: event.ctrlKey,
|
||||
meta: event.metaKey,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a keyboard event matches a shortcut configuration
|
||||
*/
|
||||
export const matchesShortcut = (
|
||||
event: KeyboardEvent,
|
||||
shortcut: KeyboardShortcut
|
||||
): boolean => {
|
||||
const parsed = parseKeyboardEvent(event);
|
||||
return (
|
||||
parsed.key === shortcut.key.toLowerCase() &&
|
||||
parsed.modifiers.alt === shortcut.modifiers.alt &&
|
||||
parsed.modifiers.shift === shortcut.modifiers.shift &&
|
||||
parsed.modifiers.ctrl === shortcut.modifiers.ctrl &&
|
||||
parsed.modifiers.meta === shortcut.modifiers.meta
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an element is an input field where shortcuts should be disabled
|
||||
*/
|
||||
export const isInputElement = (element: EventTarget | null): boolean => {
|
||||
if (!element || !(element instanceof HTMLElement)) return false;
|
||||
|
||||
const tagName = element.tagName.toUpperCase();
|
||||
return (
|
||||
tagName === 'INPUT' ||
|
||||
tagName === 'TEXTAREA' ||
|
||||
element.isContentEditable
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates shortcuts for duplicates
|
||||
* Returns an array of conflict descriptions
|
||||
*/
|
||||
export const validateShortcuts = (shortcuts: KeyboardShortcut[]): {
|
||||
valid: boolean;
|
||||
duplicates: string[];
|
||||
} => {
|
||||
const seen = new Map<string, string>();
|
||||
const duplicates: string[] = [];
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
const key = formatShortcutDisplay(shortcut);
|
||||
if (seen.has(key)) {
|
||||
duplicates.push(`${key} (${shortcut.action} and ${seen.get(key)})`);
|
||||
} else {
|
||||
seen.set(key, shortcut.action);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: duplicates.length === 0,
|
||||
duplicates,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a fresh copy of default shortcuts
|
||||
*/
|
||||
export const getDefaultShortcuts = (): KeyboardShortcut[] => {
|
||||
return JSON.parse(JSON.stringify(DEFAULT_SHORTCUTS));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns default config with defaults
|
||||
*/
|
||||
export const getDefaultConfig = (): KeyboardShortcutsConfig => {
|
||||
return {
|
||||
shortcuts: getDefaultShortcuts(),
|
||||
enabled: true,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a shortcut by action
|
||||
*/
|
||||
export const getShortcutByAction = (
|
||||
shortcuts: KeyboardShortcut[],
|
||||
action: ShortcutAction
|
||||
): KeyboardShortcut | undefined => {
|
||||
return shortcuts.find(s => s.action === action);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a shortcut string for comparison (used for duplicate detection)
|
||||
*/
|
||||
export const shortcutToString = (shortcut: KeyboardShortcut): string => {
|
||||
const mods = [];
|
||||
if (shortcut.modifiers.ctrl) mods.push('ctrl');
|
||||
if (shortcut.modifiers.alt) mods.push('alt');
|
||||
if (shortcut.modifiers.shift) mods.push('shift');
|
||||
if (shortcut.modifiers.meta) mods.push('meta');
|
||||
mods.sort();
|
||||
return `${mods.join('+')}+${shortcut.key.toLowerCase()}`;
|
||||
};
|
||||
|
|
@ -310,6 +310,25 @@
|
|||
"productivityAssistant": "Productivity Assistant",
|
||||
"productivityAssistantDescription": "Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.",
|
||||
"enableProductivityAssistant": "Enable Productivity Insights",
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"shortcuts": {
|
||||
"enableShortcuts": "Enable Keyboard Shortcuts",
|
||||
"enableDescription": "Turn keyboard shortcuts on or off globally.",
|
||||
"duplicateWarning": "Duplicate shortcuts detected:",
|
||||
"pressKeys": "Press keys...",
|
||||
"duplicateWith": "Conflicts with {{action}}",
|
||||
"record": "Record",
|
||||
"resetToDefaults": "Reset to Defaults",
|
||||
"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.",
|
||||
"actions": {
|
||||
"inbox": "Create new Inbox item",
|
||||
"task": "Create new Task",
|
||||
"project": "Create new Project",
|
||||
"note": "Create new Note",
|
||||
"area": "Create new Area",
|
||||
"tag": "Create new Tag"
|
||||
}
|
||||
},
|
||||
"nextTaskSuggestion": "Next Task Suggestion",
|
||||
"nextTaskSuggestionDescription": "Automatically suggest the next best task to work on when you have nothing in progress, prioritizing due today tasks, then suggested tasks from your Today page.",
|
||||
"enableNextTaskSuggestion": "Enable Next Task Suggestions",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue