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
+2 -5
View File
@@ -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
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">
-14
View File
@@ -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]);
}
+4
View File
@@ -0,0 +1,4 @@
import { atom } from "jotai";
export const commandPaletteOpenAtom = atom(false);
export const helpOpenAtom = atom(false);
+16
View File
@@ -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
View File
@@ -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",
+71 -14
View File
@@ -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: {}