Files
sofa/components/keyboard-provider.tsx
T
jake dcb48eaf37 Add /dashboard route, mark-all-watched on complete, keyboard fixes
- Move dashboard page to app/(pages)/dashboard, redirect / to /dashboard
  for authenticated users, update all nav/breadcrumb/auth hrefs
- Mark all episodes watched when a TV show status is set to "completed"
  (markAllEpisodesWatched skips already-watched episodes)
- Refactor KeyboardProvider to store shortcuts in a ref instead of
  state, eliminating unnecessary re-renders; use react-hotkeys-hook for
  Cmd+K so it works reliably in form inputs
- Fix useRegisterShortcut: re-register on every render (stable ref read)
  with a separate cleanup effect, removing brittle key memoization
- Search page: silently navigate to title detail on import instead of
  showing "Added to library" toast
2026-03-01 11:06:26 -05:00

134 lines
3.8 KiB
TypeScript

"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>
);
}