From 9d0961f238d4a99bac9e7886b8b4e8ad321297a0 Mon Sep 17 00:00:00 2001 From: Turawa2 Date: Tue, 28 Apr 2026 02:45:34 +0100 Subject: [PATCH 1/3] Power users lack keyboard shortcuts for common actions. --- src/components/CommandPalette.tsx | 300 ++++++++++++++++++++++++++++++ src/hooks/useAccessibility.tsx | 4 +- src/hooks/useKeyboardShortcuts.ts | 244 ++++++++++++++++++++++++ src/providers/RootProviders.tsx | 2 + 4 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 src/components/CommandPalette.tsx create mode 100644 src/hooks/useKeyboardShortcuts.ts diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 00000000..57eec3c0 --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,300 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useTheme } from '@/lib/theme-provider'; +import { + type ShortcutActionId, + type ShortcutCommand, + useKeyboardShortcuts, +} from '@/hooks/useKeyboardShortcuts'; + +function navigateTo(path: string): void { + if (typeof window === 'undefined') return; + if (window.location.pathname === path) return; + window.location.assign(path); +} + +function findSearchInput(): HTMLInputElement | null { + if (typeof document === 'undefined') return null; + return ( + document.querySelector('input[type="search"]') ?? + document.querySelector('input[placeholder*="Search" i]') ?? + document.querySelector('[aria-label*="search" i]') + ); +} + +interface ShortcutRowProps { + actionId: ShortcutActionId; + label: string; + description: string; + binding: string; + defaultBinding: string; + onSave: (id: ShortcutActionId, binding: string) => void; + onReset: (id: ShortcutActionId) => void; +} + +function ShortcutRow({ + actionId, + label, + description, + binding, + defaultBinding, + onSave, + onReset, +}: ShortcutRowProps) { + const [draft, setDraft] = useState(binding); + + return ( +
+
+
{label}
+
{description}
+
+
+ setDraft(e.target.value)} + className="w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100" + aria-label={`${label} shortcut`} + /> + + +
+
+ ); +} + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [showHelp, setShowHelp] = useState(false); + const [query, setQuery] = useState(''); + const { theme, setTheme } = useTheme(); + + const commands = useMemo(() => { + return [ + { + id: 'openCommandPalette', + title: 'Open command palette', + description: 'Show all available commands', + run: () => setOpen(true), + }, + { + id: 'goHome', + title: 'Go to Home', + description: 'Navigate to the homepage', + run: () => navigateTo('/'), + }, + { + id: 'goCourses', + title: 'Go to Courses', + description: 'Navigate to courses', + run: () => navigateTo('/courses'), + }, + { + id: 'goDashboard', + title: 'Go to Dashboard', + description: 'Navigate to your dashboard', + run: () => navigateTo('/dashboard'), + }, + { + id: 'goSettings', + title: 'Go to Settings', + description: 'Navigate to settings page', + run: () => navigateTo('/settings'), + }, + { + id: 'toggleTheme', + title: 'Toggle theme', + description: 'Switch between light and dark mode', + run: () => setTheme(theme === 'dark' ? 'light' : 'dark'), + }, + { + id: 'focusSearch', + title: 'Focus search', + description: 'Focus first available search input', + run: () => findSearchInput()?.focus(), + }, + { + id: 'openShortcutHelp', + title: 'Show keyboard shortcuts', + description: 'Open shortcuts help and customization panel', + run: () => setShowHelp(true), + }, + ]; + }, [setTheme, theme]); + + const { + shortcuts, + setShortcutBinding, + resetShortcutBinding, + resetAllShortcutBindings, + runShortcutAction, + } = useKeyboardShortcuts(commands, true); + + const filtered = useMemo(() => { + const value = query.trim().toLowerCase(); + if (!value) return commands; + return commands.filter( + (command) => + command.title.toLowerCase().includes(value) || + command.description.toLowerCase().includes(value), + ); + }, [commands, query]); + + return ( + <> + {open ? ( + <> +
setOpen(false)} + aria-hidden="true" + /> +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') setOpen(false); + }} + placeholder="Type a command..." + className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100" + /> + +
+ {filtered.map((command) => { + const shortcut = shortcuts.find((item) => item.id === command.id); + return ( + + ); + })} +
+ +
+ Press Esc to close + +
+
+ + ) : null} + + {showHelp ? ( + <> +
setShowHelp(false)} + aria-hidden="true" + /> +
+
+

+ Keyboard shortcuts +

+ +
+ +

+ Customize bindings for major actions. Use format like mod+k,{' '} + ctrl+shift+s, or meta+/. +

+ +
+ {shortcuts.map((shortcut) => ( + + ))} +
+ +
+ + +
+
+ + ) : null} + + ); +} diff --git a/src/hooks/useAccessibility.tsx b/src/hooks/useAccessibility.tsx index 942749d2..4145cd00 100644 --- a/src/hooks/useAccessibility.tsx +++ b/src/hooks/useAccessibility.tsx @@ -40,8 +40,8 @@ export function useKeyboardNavigation(enabled: boolean = true) { if (!enabled || !containerRef.current) return; const handleKeyDown = (event: KeyboardEvent) => { - // Skip links (Ctrl/Cmd + K) - if ((event.ctrlKey || event.metaKey) && event.key === 'k') { + // Skip links (Alt + S) + if (event.altKey && event.key.toLowerCase() === 's') { event.preventDefault(); const skipLink = document.querySelector('[data-skip-link]'); skipLink?.focus(); diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..9b249e93 --- /dev/null +++ b/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,244 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +const STORAGE_KEY = 'teachlink-keyboard-shortcuts-v1'; + +export type ShortcutActionId = + | 'openCommandPalette' + | 'goHome' + | 'goCourses' + | 'goDashboard' + | 'goSettings' + | 'toggleTheme' + | 'focusSearch' + | 'openShortcutHelp'; + +export interface ShortcutDefinition { + id: ShortcutActionId; + label: string; + description: string; + category: 'Navigation' | 'Interface'; + defaultBinding: string; + binding: string; +} + +export interface ShortcutCommand { + id: ShortcutActionId; + title: string; + description: string; + run: () => void; +} + +const DEFAULT_SHORTCUTS: ShortcutDefinition[] = [ + { + id: 'openCommandPalette', + label: 'Open command palette', + description: 'Open command palette for quick actions', + category: 'Interface', + defaultBinding: 'mod+k', + binding: 'mod+k', + }, + { + id: 'goHome', + label: 'Go to Home', + description: 'Navigate to home page', + category: 'Navigation', + defaultBinding: 'mod+shift+h', + binding: 'mod+shift+h', + }, + { + id: 'goCourses', + label: 'Go to Courses', + description: 'Navigate to courses page', + category: 'Navigation', + defaultBinding: 'mod+shift+c', + binding: 'mod+shift+c', + }, + { + id: 'goDashboard', + label: 'Go to Dashboard', + description: 'Navigate to dashboard page', + category: 'Navigation', + defaultBinding: 'mod+shift+d', + binding: 'mod+shift+d', + }, + { + id: 'goSettings', + label: 'Go to Settings', + description: 'Navigate to settings page', + category: 'Navigation', + defaultBinding: 'mod+shift+s', + binding: 'mod+shift+s', + }, + { + id: 'toggleTheme', + label: 'Toggle theme', + description: 'Switch between light and dark theme', + category: 'Interface', + defaultBinding: 'mod+shift+t', + binding: 'mod+shift+t', + }, + { + id: 'focusSearch', + label: 'Focus search', + description: 'Focus search field when available', + category: 'Interface', + defaultBinding: 'mod+/', + binding: 'mod+/', + }, + { + id: 'openShortcutHelp', + label: 'Show shortcuts help', + description: 'Open keyboard shortcuts help overlay', + category: 'Interface', + defaultBinding: 'mod+shift+/', + binding: 'mod+shift+/', + }, +]; + +function normalizeBinding(input: string): string { + return input + .toLowerCase() + .replace(/\s+/g, '') + .replace('cmd', 'meta') + .replace('command', 'meta') + .replace('control', 'ctrl') + .replace('option', 'alt'); +} + +function eventToBinding(event: KeyboardEvent): string { + const parts: string[] = []; + if (event.ctrlKey) parts.push('ctrl'); + if (event.metaKey) parts.push('meta'); + if (event.altKey) parts.push('alt'); + if (event.shiftKey) parts.push('shift'); + const key = event.key.toLowerCase() === ' ' ? 'space' : event.key.toLowerCase(); + parts.push(key); + return normalizeBinding(parts.join('+')); +} + +function isInputLike(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + const tag = target.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return target.isContentEditable; +} + +function resolveModBinding(binding: string): string[] { + const normalized = normalizeBinding(binding); + if (!normalized.includes('mod+')) return [normalized]; + return [normalized.replace('mod+', 'ctrl+'), normalized.replace('mod+', 'meta+')]; +} + +function loadCustomBindings(): Partial> { + if (typeof window === 'undefined') return {}; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Partial>; + return parsed; + } catch { + return {}; + } +} + +export interface UseKeyboardShortcutsResult { + shortcuts: ShortcutDefinition[]; + setShortcutBinding: (id: ShortcutActionId, binding: string) => void; + resetShortcutBinding: (id: ShortcutActionId) => void; + resetAllShortcutBindings: () => void; + runShortcutAction: (id: ShortcutActionId) => void; +} + +export function useKeyboardShortcuts( + commands: ShortcutCommand[], + enabled: boolean = true, +): UseKeyboardShortcutsResult { + const [customBindings, setCustomBindings] = useState>>( + {}, + ); + + useEffect(() => { + setCustomBindings(loadCustomBindings()); + }, []); + + const commandMap = useMemo(() => { + return new Map(commands.map((command) => [command.id, command.run])); + }, [commands]); + + const shortcuts = useMemo(() => { + return DEFAULT_SHORTCUTS.map((item) => ({ + ...item, + binding: customBindings[item.id] ?? item.defaultBinding, + })); + }, [customBindings]); + + const runShortcutAction = useCallback( + (id: ShortcutActionId) => { + const handler = commandMap.get(id); + if (handler) handler(); + }, + [commandMap], + ); + + useEffect(() => { + if (!enabled) return; + + const listener = (event: KeyboardEvent) => { + if (isInputLike(event.target)) return; + const pressed = eventToBinding(event); + + const targetShortcut = shortcuts.find((shortcut) => { + const candidates = resolveModBinding(shortcut.binding); + return candidates.includes(pressed); + }); + + if (!targetShortcut) return; + + event.preventDefault(); + runShortcutAction(targetShortcut.id); + }; + + document.addEventListener('keydown', listener); + return () => document.removeEventListener('keydown', listener); + }, [enabled, runShortcutAction, shortcuts]); + + const setShortcutBinding = useCallback((id: ShortcutActionId, binding: string) => { + const normalized = normalizeBinding(binding); + if (!normalized) return; + setCustomBindings((prev) => { + const next = { ...prev, [id]: normalized }; + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } + return next; + }); + }, []); + + const resetShortcutBinding = useCallback((id: ShortcutActionId) => { + setCustomBindings((prev) => { + const next = { ...prev }; + delete next[id]; + if (typeof window !== 'undefined') { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } + return next; + }); + }, []); + + const resetAllShortcutBindings = useCallback(() => { + setCustomBindings({}); + if (typeof window !== 'undefined') { + window.localStorage.removeItem(STORAGE_KEY); + } + }, []); + + return { + shortcuts, + setShortcutBinding, + resetShortcutBinding, + resetAllShortcutBindings, + runShortcutAction, + }; +} diff --git a/src/providers/RootProviders.tsx b/src/providers/RootProviders.tsx index a639ad29..671c896f 100644 --- a/src/providers/RootProviders.tsx +++ b/src/providers/RootProviders.tsx @@ -8,6 +8,7 @@ import { InternationalizationEngine } from '@/components/i18n/Internationalizati import { CulturalAdaptationManager } from '@/components/i18n/CulturalAdaptationManager'; import { AccessibilityProvider } from '@/components/accessibility/AccessibilityProvider'; import { RouteChangeAnnouncer } from '@/components/accessibility/RouteChangeAnnouncer'; +import { CommandPalette } from '@/components/CommandPalette'; import { LegacyStorePreferencesBridge, RemoteSettingsSync, @@ -73,6 +74,7 @@ export function RootProviders({ children, defaultTheme }: RootProvidersProps) { + From 10470434a4c1afddc2514f21aa07648d2b9c5105 Mon Sep 17 00:00:00 2001 From: Turawa2 Date: Tue, 28 Apr 2026 02:53:37 +0100 Subject: [PATCH 2/3] Dark mode preference resets on page refresh. --- src/contexts/ThemeContext.tsx | 99 +++++++++++++++++++++++++++++++++++ src/hooks/useTheme.ts | 10 ++++ src/lib/theme-provider.tsx | 65 +---------------------- 3 files changed, 111 insertions(+), 63 deletions(-) create mode 100644 src/contexts/ThemeContext.tsx create mode 100644 src/hooks/useTheme.ts diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 00000000..316fcbfd --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; + +export type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextValue { + theme: Theme; + resolvedTheme: 'light' | 'dark'; + setTheme: (next: Theme) => void; +} + +const STORAGE_KEY = 'teachlink-theme-preference'; + +const ThemeContext = createContext(undefined); + +function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function sanitizeTheme(value: string | null | undefined): Theme { + if (value === 'light' || value === 'dark' || value === 'system') return value; + return 'system'; +} + +function applyThemeClass(resolved: 'light' | 'dark'): void { + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(resolved); +} + +function persistTheme(theme: Theme): void { + try { + window.localStorage.setItem(STORAGE_KEY, theme); + } catch { + // Ignore storage access failures (private mode / disabled storage). + } + document.cookie = `theme=${theme}; path=/; max-age=31536000`; +} + +export function ThemeProvider({ + children, + defaultTheme = 'system', +}: { + children: React.ReactNode; + defaultTheme?: string; +}) { + const [theme, setThemeState] = useState(sanitizeTheme(defaultTheme)); + const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => + sanitizeTheme(defaultTheme) === 'system' + ? 'light' + : (sanitizeTheme(defaultTheme) as 'light' | 'dark'), + ); + + useEffect(() => { + if (typeof window === 'undefined') return; + const stored = sanitizeTheme(window.localStorage.getItem(STORAGE_KEY)); + setThemeState(stored); + }, []); + + useEffect(() => { + const resolved = theme === 'system' ? getSystemTheme() : theme; + setResolvedTheme(resolved); + applyThemeClass(resolved); + if (typeof window !== 'undefined') persistTheme(theme); + }, [theme]); + + useEffect(() => { + if (typeof window === 'undefined') return; + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + if (theme !== 'system') return; + const next = mediaQuery.matches ? 'dark' : 'light'; + setResolvedTheme(next); + applyThemeClass(next); + }; + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); + + const value = useMemo( + () => ({ + theme, + resolvedTheme, + setTheme: (next) => setThemeState(sanitizeTheme(next)), + }), + [theme, resolvedTheme], + ); + + return {children}; +} + +export function useThemeContext(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useThemeContext must be used within ThemeProvider'); + } + return context; +} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 00000000..489ab702 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,10 @@ +'use client'; + +import { useThemeContext } from '@/contexts/ThemeContext'; + +/** + * Shared theme hook with persisted preference support. + */ +export function useTheme() { + return useThemeContext(); +} diff --git a/src/lib/theme-provider.tsx b/src/lib/theme-provider.tsx index 76efa698..89e86ff6 100644 --- a/src/lib/theme-provider.tsx +++ b/src/lib/theme-provider.tsx @@ -1,63 +1,2 @@ -'use client'; - -import React, { createContext, useContext, useEffect, useState } from 'react'; - -type Theme = 'dark' | 'light' | 'system'; - -interface ThemeProviderState { - theme: Theme; - setTheme: (theme: Theme) => void; -} - -const ThemeProviderContext = createContext(undefined); - -export function ThemeProvider({ - children, - defaultTheme = 'system', -}: { - children: React.ReactNode; - defaultTheme?: string; -}) { - const [theme, setThemeState] = useState(defaultTheme as Theme); - - const setTheme = (newTheme: Theme) => { - setThemeState(newTheme); - document.cookie = `theme=${newTheme}; path=/; max-age=31536000`; - - if (newTheme === 'system') { - const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches - ? 'dark' - : 'light'; - document.documentElement.classList.remove('light', 'dark'); - document.documentElement.classList.add(systemTheme); - } else { - document.documentElement.classList.remove('light', 'dark'); - document.documentElement.classList.add(newTheme); - } - }; - - useEffect(() => { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - const handleChange = () => { - if (theme === 'system') { - const systemTheme = mediaQuery.matches ? 'dark' : 'light'; - document.documentElement.classList.remove('light', 'dark'); - document.documentElement.classList.add(systemTheme); - } - }; - mediaQuery.addEventListener('change', handleChange); - return () => mediaQuery.removeEventListener('change', handleChange); - }, [theme]); - - return ( - - {children} - - ); -} - -export const useTheme = () => { - const context = useContext(ThemeProviderContext); - if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider'); - return context; -}; +export { ThemeProvider, type Theme } from '@/contexts/ThemeContext'; +export { useThemeContext as useTheme } from '@/contexts/ThemeContext'; From 5857192ce0998070f17abc618be754d69a853658 Mon Sep 17 00:00:00 2001 From: Turawa2 Date: Tue, 28 Apr 2026 12:30:28 +0100 Subject: [PATCH 3/3] Update dependencies and lock file --- package-lock.json | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index 850654de..4b366894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "lucide-react": "^0.462.0", "next": "15.3.1", "next-themes": "^0.4.6", + "qrcode.react": "^3.2.0", "react": "^18.3.1", "react-countdown": "^2.3.6", "react-dnd": "^16.0.1", @@ -11725,6 +11726,15 @@ ], "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.2.0.tgz", + "integrity": "sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 34e5a443..4ddd76c5 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "lucide-react": "^0.462.0", "next": "15.3.1", "next-themes": "^0.4.6", + "qrcode.react": "^3.2.0", "react": "^18.3.1", "react-countdown": "^2.3.6", "react-dnd": "^16.0.1",