mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -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:
@@ -1,8 +1,6 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { KeyboardHelpDialog } from "@/components/keyboard-help-dialog";
|
||||
import { KeyboardProvider } from "@/components/keyboard-provider";
|
||||
import { MobileTabBar } from "@/components/mobile-tab-bar";
|
||||
import { NavBar } from "@/components/nav-bar";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
@@ -16,7 +14,7 @@ export default async function PagesLayout({
|
||||
if (!session) redirect("/login");
|
||||
|
||||
return (
|
||||
<KeyboardProvider>
|
||||
<>
|
||||
<div className="min-h-screen overflow-x-hidden pb-14 sm:pb-0">
|
||||
<NavBar />
|
||||
{/* Ambient glow */}
|
||||
@@ -27,7 +25,6 @@ export default async function PagesLayout({
|
||||
</div>
|
||||
<MobileTabBar />
|
||||
<CommandPalette />
|
||||
<KeyboardHelpDialog />
|
||||
</KeyboardProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { Hotkey } from "@tanstack/react-hotkeys";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useRegisterShortcut } from "@/hooks/use-register-shortcut";
|
||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
||||
import { useTitleInteraction } from "./title-interaction-provider";
|
||||
|
||||
const statusCycle = ["watchlist", "in_progress", "completed"] as const;
|
||||
@@ -17,6 +20,9 @@ export function TitleKeyboardShortcuts() {
|
||||
handleWatchMovie,
|
||||
} = useTitleInteraction();
|
||||
|
||||
const commandPaletteOpen = useAtomValue(commandPaletteOpenAtom);
|
||||
const enabled = !commandPaletteOpen;
|
||||
|
||||
const nextStatus = useMemo(() => {
|
||||
const currentIdx = statusCycle.indexOf(
|
||||
userStatus as (typeof statusCycle)[number],
|
||||
@@ -26,38 +32,19 @@ export function TitleKeyboardShortcuts() {
|
||||
: statusCycle[currentIdx + 1];
|
||||
}, [userStatus]);
|
||||
|
||||
useRegisterShortcut("title-cycle-status", {
|
||||
keys: ["w"],
|
||||
description: "Cycle status",
|
||||
action: () => handleStatusChange(nextStatus),
|
||||
scope: "Title",
|
||||
});
|
||||
|
||||
useRegisterShortcut("title-mark-watched", {
|
||||
keys: ["m"],
|
||||
description: "Mark watched",
|
||||
action: () => {
|
||||
useHotkey("W", () => handleStatusChange(nextStatus), { enabled });
|
||||
useHotkey(
|
||||
"M",
|
||||
() => {
|
||||
if (titleType === "movie") handleWatchMovie();
|
||||
},
|
||||
scope: "Title",
|
||||
});
|
||||
{ enabled },
|
||||
);
|
||||
useHotkey("Escape", () => router.back(), { enabled });
|
||||
|
||||
useRegisterShortcut("title-escape", {
|
||||
keys: ["Escape"],
|
||||
description: "Go back",
|
||||
action: () => router.back(),
|
||||
scope: "Title",
|
||||
});
|
||||
|
||||
// Rating shortcuts 1-5
|
||||
for (const n of [1, 2, 3, 4, 5]) {
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: loop is stable
|
||||
useRegisterShortcut(`title-rate-${n}`, {
|
||||
keys: [String(n)],
|
||||
description: `Rate ${n} star${n > 1 ? "s" : ""}`,
|
||||
action: () => handleRating(n),
|
||||
scope: "Title",
|
||||
});
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: loop is stable (always 5 iterations)
|
||||
useHotkey(String(n) as Hotkey, () => handleRating(n), { enabled });
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
+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">
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { type ShortcutDef, useKeyboard } from "@/components/keyboard-provider";
|
||||
|
||||
export function useRegisterShortcut(id: string, def: ShortcutDef) {
|
||||
const { registerShortcut, unregisterShortcut } = useKeyboard();
|
||||
|
||||
useEffect(() => {
|
||||
registerShortcut(id, def);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => unregisterShortcut(id);
|
||||
}, [id, unregisterShortcut]);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { atom } from "jotai";
|
||||
|
||||
export const commandPaletteOpenAtom = atom(false);
|
||||
export const helpOpenAtom = atom(false);
|
||||
@@ -0,0 +1,16 @@
|
||||
// Static shortcut descriptions for the help dialog.
|
||||
// TanStack's HotkeyManager/SequenceManager handle all actual key listening.
|
||||
export const SHORTCUT_DESCRIPTIONS = [
|
||||
{ scope: "Global", description: "Search", keys: ["/"] },
|
||||
{ scope: "Global", description: "Keyboard shortcuts", keys: ["?"] },
|
||||
{ scope: "Navigation", description: "Go to dashboard", keys: ["g", "h"] },
|
||||
{ scope: "Navigation", description: "Go to explore", keys: ["g", "e"] },
|
||||
{ scope: "Title", description: "Cycle status", keys: ["w"] },
|
||||
{ scope: "Title", description: "Mark watched", keys: ["m"] },
|
||||
{ scope: "Title", description: "Go back", keys: ["Escape"] },
|
||||
{ scope: "Title", description: "Rate 1 star", keys: ["1"] },
|
||||
{ scope: "Title", description: "Rate 2 stars", keys: ["2"] },
|
||||
{ scope: "Title", description: "Rate 3 stars", keys: ["3"] },
|
||||
{ scope: "Title", description: "Rate 4 stars", keys: ["4"] },
|
||||
{ scope: "Title", description: "Rate 5 stars", keys: ["5"] },
|
||||
] as const;
|
||||
+2
-1
@@ -18,6 +18,7 @@
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@libsql/client": "^0.17.0",
|
||||
"@tabler/icons-react": "^3.37.1",
|
||||
"@tanstack/react-hotkeys": "^0.3.1",
|
||||
"better-auth": "^1.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -25,13 +26,13 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "1.0.0-beta.15-859cf75",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"jotai": "^2.18.0",
|
||||
"motion": "^12.34.3",
|
||||
"next": "16.1.6",
|
||||
"node-vibrant": "^4.0.4",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"react-resizable-panels": "^4.7.0",
|
||||
"recharts": "2.15.4",
|
||||
"shadcn": "^3.8.5",
|
||||
|
||||
Generated
+71
-14
@@ -17,6 +17,9 @@ importers:
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.37.1
|
||||
version: 3.37.1(react@19.2.4)
|
||||
'@tanstack/react-hotkeys':
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.5.0
|
||||
version: 1.5.0(de742afebc2fb0e9c0a69510fa92eeec)
|
||||
@@ -38,6 +41,9 @@ importers:
|
||||
embla-carousel-react:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(react@19.2.4)
|
||||
jotai:
|
||||
specifier: ^2.18.0
|
||||
version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4)
|
||||
motion:
|
||||
specifier: ^12.34.3
|
||||
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -56,9 +62,6 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-hotkeys-hook:
|
||||
specifier: ^5.2.4
|
||||
version: 5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-resizable-panels:
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -1483,6 +1486,26 @@ packages:
|
||||
'@tailwindcss/postcss@4.2.1':
|
||||
resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==}
|
||||
|
||||
'@tanstack/hotkeys@0.3.1':
|
||||
resolution: {integrity: sha512-G+v+PUac8ff/jg752yhhfPqw7/0xr8RYKL69Jb4i7L1JCPKwwoO4aLqVSmI17WSDN6sh1+nNf8QzUOphuPLAuw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@tanstack/react-hotkeys@0.3.1':
|
||||
resolution: {integrity: sha512-QXTIwVy4mdLZD0Fz501hzw85aNEI9522kLnxF85Bgyr6iKSHYq5L6ChBsBVx28d4zbyEiDQ6HHf96/qzIcNtOg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8'
|
||||
react-dom: '>=16.8'
|
||||
|
||||
'@tanstack/react-store@0.9.1':
|
||||
resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/store@0.9.1':
|
||||
resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==}
|
||||
|
||||
'@tediousjs/connection-string@0.5.0':
|
||||
resolution: {integrity: sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ==}
|
||||
|
||||
@@ -2701,6 +2724,24 @@ packages:
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
jotai@2.18.0:
|
||||
resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@babel/core': '>=7.0.0'
|
||||
'@babel/template': '>=7.0.0'
|
||||
'@types/react': '>=17.0.0'
|
||||
react: '>=17.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@babel/core':
|
||||
optional: true
|
||||
'@babel/template':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
jpeg-js@0.4.4:
|
||||
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
|
||||
|
||||
@@ -3353,12 +3394,6 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-hotkeys-hook@5.2.4:
|
||||
resolution: {integrity: sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
@@ -5287,6 +5322,26 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
tailwindcss: 4.2.1
|
||||
|
||||
'@tanstack/hotkeys@0.3.1':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.9.1
|
||||
|
||||
'@tanstack/react-hotkeys@0.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/hotkeys': 0.3.1
|
||||
'@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@tanstack/react-store@0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@tanstack/store': 0.9.1
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
|
||||
'@tanstack/store@0.9.1': {}
|
||||
|
||||
'@tediousjs/connection-string@0.5.0': {}
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
@@ -6367,6 +6422,13 @@ snapshots:
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4):
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/template': 7.28.6
|
||||
'@types/react': 19.2.14
|
||||
react: 19.2.4
|
||||
|
||||
jpeg-js@0.4.4: {}
|
||||
|
||||
js-base64@3.7.8: {}
|
||||
@@ -6986,11 +7048,6 @@ snapshots:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-hotkeys-hook@5.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
Reference in New Issue
Block a user