Replace keyboard system with TanStack Hotkeys and jotai atoms

Remove react-hotkeys-hook, KeyboardProvider context, useRegisterShortcut
wrapper, and KeyboardHelpDialog component. Components now call TanStack
useHotkey/useHotkeySequence directly. Shared state uses jotai atoms in
lib/atoms/, shortcut metadata lives as a static const in lib/constants/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 11:48:14 -05:00
co-authored by Claude Opus 4.6
parent d5e985efd9
commit ec256b7a96
11 changed files with 337 additions and 441 deletions
+223 -166
View File
@@ -7,10 +7,11 @@ import {
IconMovie,
IconSearch,
} from "@tabler/icons-react";
import { useHotkey, useHotkeySequence } from "@tanstack/react-hotkeys";
import { useAtom } from "jotai";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useKeyboard } from "@/components/keyboard-provider";
import {
Command,
CommandEmpty,
@@ -28,8 +29,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebounce } from "@/hooks/use-debounce";
import {
commandPaletteOpenAtom,
helpOpenAtom,
} from "@/lib/atoms/command-palette";
import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts";
interface SearchResult {
tmdbId: number;
@@ -60,45 +67,28 @@ function addRecentSearch(query: string) {
export function CommandPalette() {
const router = useRouter();
const {
commandPaletteOpen,
setCommandPaletteOpen,
setHelpOpen,
registerShortcut,
} = useKeyboard();
const [commandPaletteOpen, setCommandPaletteOpen] = useAtom(
commandPaletteOpenAtom,
);
const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const debouncedQuery = useDebounce(query, 300);
const enabled = !commandPaletteOpen;
// Register global shortcuts
useEffect(() => {
registerShortcut("cmd-palette-slash", {
keys: ["/"],
description: "Search",
action: () => setCommandPaletteOpen(true),
scope: "Global",
});
registerShortcut("cmd-palette-help", {
keys: ["?"],
description: "Keyboard shortcuts",
action: () => setHelpOpen(true),
scope: "Global",
});
registerShortcut("nav-home", {
keys: ["g", "h"],
description: "Go to dashboard",
action: () => router.push("/dashboard"),
scope: "Navigation",
});
registerShortcut("nav-explore", {
keys: ["g", "e"],
description: "Go to explore",
action: () => router.push("/explore"),
scope: "Navigation",
});
}, [registerShortcut, setCommandPaletteOpen, setHelpOpen, router]);
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev));
useHotkey("/", () => setCommandPaletteOpen(true), { enabled });
useHotkey({ key: "?", shift: true }, () => setHelpOpen(true), { enabled });
useHotkeySequence(["G", "H"], () => router.push("/dashboard"), {
enabled,
timeout: 500,
});
useHotkeySequence(["G", "E"], () => router.push("/explore"), {
enabled,
timeout: 500,
});
// Load recent searches when palette opens
useEffect(() => {
@@ -147,142 +137,209 @@ export function CommandPalette() {
const hasQuery = query.trim().length > 0;
// Group shortcuts by scope for help dialog
const grouped: Record<
string,
{ description: string; keys: readonly string[] }[]
> = {};
for (const entry of SHORTCUT_DESCRIPTIONS) {
if (!grouped[entry.scope]) grouped[entry.scope] = [];
grouped[entry.scope].push(entry);
}
return (
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle>
<DialogDescription>
Search for movies, TV shows, or run commands
</DialogDescription>
</DialogHeader>
<DialogContent
className="top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-lg"
showCloseButton={false}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search movies & TV shows..."
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-80">
{hasQuery && loading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={`skel-${i}`} className="flex items-center gap-3">
<Skeleton className="h-12 w-8 shrink-0 rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
))}
</div>
)}
{hasQuery && !loading && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{hasQuery && !loading && results.length > 0 && (
<CommandGroup heading="Results">
{results.map((r) => (
<CommandItem
key={`${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2"
>
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
{r.posterPath ? (
<Image
src={r.posterPath as string}
alt={r.title}
width={32}
height={48}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
?
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{r.title}</p>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{r.type === "movie" ? (
<IconMovie size={11} />
) : (
<IconDeviceTv size={11} />
)}
<span className="uppercase">{r.type}</span>
{r.releaseDate && (
<span>{r.releaseDate.slice(0, 4)}</span>
)}
<>
<Dialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Command Palette</DialogTitle>
<DialogDescription>
Search for movies, TV shows, or run commands
</DialogDescription>
</DialogHeader>
<DialogContent
className="top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0 sm:max-w-lg"
showCloseButton={false}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search movies & TV shows..."
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-80">
{hasQuery && loading && (
<div className="space-y-2 p-3">
{Array.from({ length: 3 }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
<div key={`skel-${i}`} className="flex items-center gap-3">
<Skeleton className="h-12 w-8 shrink-0 rounded" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/3" />
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
))}
</div>
)}
{!hasQuery && (
<>
{recentSearches.length > 0 && (
<CommandGroup heading="Recent Searches">
{recentSearches.map((q) => (
<CommandItem
key={q}
onSelect={() => handleRecentSearch(q)}
>
<IconSearch
size={14}
className="text-muted-foreground"
/>
{q}
</CommandItem>
))}
</CommandGroup>
)}
{recentSearches.length > 0 && <CommandSeparator />}
<CommandGroup heading="Quick Actions">
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
router.push("/dashboard");
}}
>
<IconHome size={14} />
Go to Dashboard
<CommandShortcut>G H</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
router.push("/explore");
}}
>
<IconSearch size={14} />
Go to Explore
<CommandShortcut>G E</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
setHelpOpen(true);
}}
>
<IconKeyboard size={14} />
Keyboard Shortcuts
<CommandShortcut>?</CommandShortcut>
</CommandItem>
{hasQuery && !loading && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{hasQuery && !loading && results.length > 0 && (
<CommandGroup heading="Results">
{results.map((r) => (
<CommandItem
key={`${r.type}-${r.tmdbId}`}
onSelect={() => handleSelect(r)}
className="flex items-center gap-3 py-2"
>
<div className="h-12 w-8 shrink-0 overflow-hidden rounded bg-muted">
{r.posterPath ? (
<Image
src={r.posterPath as string}
alt={r.title}
width={32}
height={48}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-[8px] text-muted-foreground">
?
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">
{r.title}
</p>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{r.type === "movie" ? (
<IconMovie size={11} />
) : (
<IconDeviceTv size={11} />
)}
<span className="uppercase">{r.type}</span>
{r.releaseDate && (
<span>{r.releaseDate.slice(0, 4)}</span>
)}
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
)}
{!hasQuery && (
<>
{recentSearches.length > 0 && (
<CommandGroup heading="Recent Searches">
{recentSearches.map((q) => (
<CommandItem
key={q}
onSelect={() => handleRecentSearch(q)}
>
<IconSearch
size={14}
className="text-muted-foreground"
/>
{q}
</CommandItem>
))}
</CommandGroup>
)}
{recentSearches.length > 0 && <CommandSeparator />}
<CommandGroup heading="Quick Actions">
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
router.push("/dashboard");
}}
>
<IconHome size={14} />
Go to Dashboard
<CommandShortcut>G H</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
router.push("/explore");
}}
>
<IconSearch size={14} />
Go to Explore
<CommandShortcut>G E</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setCommandPaletteOpen(false);
setHelpOpen(true);
}}
>
<IconKeyboard size={14} />
Keyboard Shortcuts
<CommandShortcut>?</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{Object.entries(grouped).map(([scope, items]) => (
<div key={scope} className="space-y-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{scope}
</h3>
<div className="space-y-1">
{items.map((item) => (
<div
key={item.description}
className="flex items-center justify-between rounded-md px-2 py-1.5"
>
<span className="text-xs text-foreground">
{item.description}
</span>
<div className="flex items-center gap-1">
{item.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1">
{i > 0 && (
<span className="text-[10px] text-muted-foreground">
then
</span>
)}
<Kbd>{formatKey(key)}</Kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
);
}
function formatKey(key: string): string {
const map: Record<string, string> = {
" ": "Space",
Escape: "Esc",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
};
return map[key] ?? key.toUpperCase();
}
-77
View File
@@ -1,77 +0,0 @@
"use client";
import { useKeyboard } from "@/components/keyboard-provider";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Kbd } from "@/components/ui/kbd";
export function KeyboardHelpDialog() {
const { shortcutsRef, helpOpen, setHelpOpen } = useKeyboard();
// Group shortcuts by scope
const grouped: Record<string, { description: string; keys: string[] }[]> = {};
for (const def of shortcutsRef.current.values()) {
const scope = def.scope ?? "Global";
if (!grouped[scope]) grouped[scope] = [];
grouped[scope].push({ description: def.description, keys: def.keys });
}
return (
<Dialog open={helpOpen} onOpenChange={setHelpOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{Object.entries(grouped).map(([scope, items]) => (
<div key={scope} className="space-y-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{scope}
</h3>
<div className="space-y-1">
{items.map((item) => (
<div
key={item.description}
className="flex items-center justify-between rounded-md px-2 py-1.5"
>
<span className="text-xs text-foreground">
{item.description}
</span>
<div className="flex items-center gap-1">
{item.keys.map((key, i) => (
<span key={key} className="flex items-center gap-1">
{i > 0 && (
<span className="text-[10px] text-muted-foreground">
then
</span>
)}
<Kbd>{formatKey(key)}</Kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
function formatKey(key: string): string {
const map: Record<string, string> = {
" ": "Space",
Escape: "Esc",
ArrowUp: "↑",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
};
return map[key] ?? key.toUpperCase();
}
-133
View File
@@ -1,133 +0,0 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useHotkeys } from "react-hotkeys-hook";
export interface ShortcutDef {
keys: string[];
description: string;
action: () => void;
scope?: string;
}
interface KeyboardContextValue {
shortcutsRef: React.RefObject<Map<string, ShortcutDef>>;
registerShortcut: (id: string, def: ShortcutDef) => void;
unregisterShortcut: (id: string) => void;
commandPaletteOpen: boolean;
setCommandPaletteOpen: (open: boolean) => void;
helpOpen: boolean;
setHelpOpen: (open: boolean) => void;
}
const KeyboardContext = createContext<KeyboardContextValue | null>(null);
export function useKeyboard() {
const ctx = useContext(KeyboardContext);
if (!ctx) throw new Error("useKeyboard must be used within KeyboardProvider");
return ctx;
}
export function KeyboardProvider({ children }: { children: React.ReactNode }) {
const shortcutsRef = useRef<Map<string, ShortcutDef>>(new Map());
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const commandPaletteOpenRef = useRef(false);
commandPaletteOpenRef.current = commandPaletteOpen;
const pendingKeyRef = useRef<string | null>(null);
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const registerShortcut = useCallback((id: string, def: ShortcutDef) => {
shortcutsRef.current.set(id, def);
}, []);
const unregisterShortcut = useCallback((id: string) => {
shortcutsRef.current.delete(id);
}, []);
// Cmd/Ctrl+K: toggle command palette (always works, even in inputs)
useHotkeys("mod+k", () => setCommandPaletteOpen((prev) => !prev), {
preventDefault: true,
enableOnFormTags: ["INPUT", "TEXTAREA"],
enableOnContentEditable: true,
});
// Registered shortcuts handler (single keys + key sequences)
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const target = e.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
const isInput =
tagName === "input" ||
tagName === "textarea" ||
target.isContentEditable;
if (isInput && e.key !== "Escape") return;
if (commandPaletteOpenRef.current && e.key !== "Escape") return;
const shortcuts = shortcutsRef.current;
if (pendingKeyRef.current) {
const firstKey = pendingKeyRef.current;
pendingKeyRef.current = null;
if (pendingTimerRef.current) {
clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
for (const def of shortcuts.values()) {
if (
def.keys.length === 2 &&
def.keys[0] === firstKey &&
def.keys[1] === e.key
) {
e.preventDefault();
def.action();
return;
}
}
}
for (const def of shortcuts.values()) {
if (def.keys.length === 1 && def.keys[0] === e.key) {
e.preventDefault();
def.action();
return;
}
if (def.keys.length === 2 && def.keys[0] === e.key) {
e.preventDefault();
pendingKeyRef.current = e.key;
pendingTimerRef.current = setTimeout(() => {
pendingKeyRef.current = null;
}, 500);
return;
}
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<KeyboardContext.Provider
value={{
shortcutsRef,
registerShortcut,
unregisterShortcut,
commandPaletteOpen,
setCommandPaletteOpen,
helpOpen,
setHelpOpen,
}}
>
{children}
</KeyboardContext.Provider>
);
}
+3 -2
View File
@@ -1,12 +1,13 @@
"use client";
import { IconLogout, IconSearch, IconSettings } from "@tabler/icons-react";
import { useSetAtom } from "jotai";
import { motion } from "motion/react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useKeyboard } from "@/components/keyboard-provider";
import { SofaLogo } from "@/components/sofa-logo";
import { Kbd } from "@/components/ui/kbd";
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
import { signOut, useSession } from "@/lib/auth/client";
const navLinks = [
@@ -18,7 +19,7 @@ export function NavBar() {
const { data: session } = useSession();
const router = useRouter();
const pathname = usePathname();
const { setCommandPaletteOpen } = useKeyboard();
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
return (
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">