mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
+223
-166
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user