mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 06:15:39 -04:00
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
This commit is contained in:
@@ -35,11 +35,7 @@ export default function SearchPage() {
|
|||||||
if (l) setSearched(true);
|
if (l) setSearched(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleImport(
|
async function handleOpen(tmdbId: number, type: "movie" | "tv") {
|
||||||
tmdbId: number,
|
|
||||||
type: "movie" | "tv",
|
|
||||||
title: string,
|
|
||||||
) {
|
|
||||||
setImporting(tmdbId);
|
setImporting(tmdbId);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/titles/import", {
|
const res = await fetch("/api/titles/import", {
|
||||||
@@ -49,11 +45,10 @@ export default function SearchPage() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
toast.success(`Added "${title}" to library`);
|
|
||||||
router.push(`/titles/${data.id}`);
|
router.push(`/titles/${data.id}`);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to import title");
|
toast.error("Failed to load title");
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(null);
|
setImporting(null);
|
||||||
}
|
}
|
||||||
@@ -64,7 +59,7 @@ export default function SearchPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="font-display text-3xl tracking-tight">Search</h1>
|
<h1 className="font-display text-3xl tracking-tight">Search</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Find movies and TV shows to add to your library
|
Find movies and TV shows to track
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -91,14 +86,14 @@ export default function SearchPage() {
|
|||||||
posterPath={r.posterPath}
|
posterPath={r.posterPath}
|
||||||
releaseDate={r.releaseDate}
|
releaseDate={r.releaseDate}
|
||||||
voteAverage={r.voteAverage}
|
voteAverage={r.voteAverage}
|
||||||
onImport={() => handleImport(r.tmdbId, r.type, r.title)}
|
onImport={() => handleOpen(r.tmdbId, r.type)}
|
||||||
/>
|
/>
|
||||||
{importing === r.tmdbId && (
|
{importing === r.tmdbId && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-background/80 backdrop-blur-sm">
|
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-background/80 backdrop-blur-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
<span className="text-sm font-medium text-primary">
|
<span className="text-sm font-medium text-primary">
|
||||||
Importing
|
Loading
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -380,7 +380,9 @@ export default function TitleDetailPage() {
|
|||||||
<Breadcrumb className="relative z-20">
|
<Breadcrumb className="relative z-20">
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink render={<Link href="/" />}>Home</BreadcrumbLink>
|
<BreadcrumbLink render={<Link href="/dashboard" />}>
|
||||||
|
Home
|
||||||
|
</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
<BreadcrumbSeparator />
|
<BreadcrumbSeparator />
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
|
|||||||
@@ -2,8 +2,22 @@
|
|||||||
|
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSession } from "@/lib/auth/client";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { data: session, isPending } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPending && session?.user) {
|
||||||
|
router.replace("/dashboard");
|
||||||
|
}
|
||||||
|
}, [session, isPending, router]);
|
||||||
|
|
||||||
|
if (isPending) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden">
|
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden">
|
||||||
{/* Background grain texture */}
|
{/* Background grain texture */}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function AuthForm({ mode }: { mode: "login" | "register" }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
router.push("/");
|
router.push("/dashboard");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch {
|
} catch {
|
||||||
setError("Something went wrong");
|
setError("Something went wrong");
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function CommandPalette() {
|
|||||||
registerShortcut("nav-home", {
|
registerShortcut("nav-home", {
|
||||||
keys: ["g", "h"],
|
keys: ["g", "h"],
|
||||||
description: "Go to dashboard",
|
description: "Go to dashboard",
|
||||||
action: () => router.push("/"),
|
action: () => router.push("/dashboard"),
|
||||||
scope: "Navigation",
|
scope: "Navigation",
|
||||||
});
|
});
|
||||||
registerShortcut("nav-search", {
|
registerShortcut("nav-search", {
|
||||||
@@ -268,7 +268,7 @@ export function CommandPalette() {
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setCommandPaletteOpen(false);
|
setCommandPaletteOpen(false);
|
||||||
router.push("/");
|
router.push("/dashboard");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IconHome size={14} />
|
<IconHome size={14} />
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import {
|
|||||||
import { Kbd } from "@/components/ui/kbd";
|
import { Kbd } from "@/components/ui/kbd";
|
||||||
|
|
||||||
export function KeyboardHelpDialog() {
|
export function KeyboardHelpDialog() {
|
||||||
const { shortcuts, helpOpen, setHelpOpen } = useKeyboard();
|
const { shortcutsRef, helpOpen, setHelpOpen } = useKeyboard();
|
||||||
|
|
||||||
// Group shortcuts by scope
|
// Group shortcuts by scope
|
||||||
const grouped: Record<string, { description: string; keys: string[] }[]> = {};
|
const grouped: Record<string, { description: string; keys: string[] }[]> = {};
|
||||||
for (const def of shortcuts.values()) {
|
for (const def of shortcutsRef.current.values()) {
|
||||||
const scope = def.scope ?? "Global";
|
const scope = def.scope ?? "Global";
|
||||||
if (!grouped[scope]) grouped[scope] = [];
|
if (!grouped[scope]) grouped[scope] = [];
|
||||||
grouped[scope].push({ description: def.description, keys: def.keys });
|
grouped[scope].push({ description: def.description, keys: def.keys });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useHotkeys } from "react-hotkeys-hook";
|
||||||
|
|
||||||
export interface ShortcutDef {
|
export interface ShortcutDef {
|
||||||
keys: string[];
|
keys: string[];
|
||||||
@@ -17,7 +18,7 @@ export interface ShortcutDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface KeyboardContextValue {
|
interface KeyboardContextValue {
|
||||||
shortcuts: Map<string, ShortcutDef>;
|
shortcutsRef: React.RefObject<Map<string, ShortcutDef>>;
|
||||||
registerShortcut: (id: string, def: ShortcutDef) => void;
|
registerShortcut: (id: string, def: ShortcutDef) => void;
|
||||||
unregisterShortcut: (id: string) => void;
|
unregisterShortcut: (id: string) => void;
|
||||||
commandPaletteOpen: boolean;
|
commandPaletteOpen: boolean;
|
||||||
@@ -35,30 +36,30 @@ export function useKeyboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KeyboardProvider({ children }: { children: React.ReactNode }) {
|
export function KeyboardProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [shortcuts, setShortcuts] = useState<Map<string, ShortcutDef>>(
|
const shortcutsRef = useRef<Map<string, ShortcutDef>>(new Map());
|
||||||
() => new Map(),
|
|
||||||
);
|
|
||||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
const commandPaletteOpenRef = useRef(false);
|
||||||
|
commandPaletteOpenRef.current = commandPaletteOpen;
|
||||||
const pendingKeyRef = useRef<string | null>(null);
|
const pendingKeyRef = useRef<string | null>(null);
|
||||||
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const registerShortcut = useCallback((id: string, def: ShortcutDef) => {
|
const registerShortcut = useCallback((id: string, def: ShortcutDef) => {
|
||||||
setShortcuts((prev) => {
|
shortcutsRef.current.set(id, def);
|
||||||
const next = new Map(prev);
|
|
||||||
next.set(id, def);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unregisterShortcut = useCallback((id: string) => {
|
const unregisterShortcut = useCallback((id: string) => {
|
||||||
setShortcuts((prev) => {
|
shortcutsRef.current.delete(id);
|
||||||
const next = new Map(prev);
|
|
||||||
next.delete(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -68,22 +69,13 @@ export function KeyboardProvider({ children }: { children: React.ReactNode }) {
|
|||||||
tagName === "textarea" ||
|
tagName === "textarea" ||
|
||||||
target.isContentEditable;
|
target.isContentEditable;
|
||||||
|
|
||||||
// Cmd+K / Ctrl+K always works
|
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
|
||||||
e.preventDefault();
|
|
||||||
setCommandPaletteOpen((prev) => !prev);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't fire shortcuts when typing in inputs (except Escape)
|
|
||||||
if (isInput && e.key !== "Escape") return;
|
if (isInput && e.key !== "Escape") return;
|
||||||
|
if (commandPaletteOpenRef.current && e.key !== "Escape") return;
|
||||||
|
|
||||||
// Don't fire when command palette is open
|
const shortcuts = shortcutsRef.current;
|
||||||
if (commandPaletteOpen && e.key !== "Escape") return;
|
|
||||||
|
|
||||||
// Check for two-key combos
|
|
||||||
if (pendingKeyRef.current) {
|
if (pendingKeyRef.current) {
|
||||||
const comboKey = `${pendingKeyRef.current}+${e.key}`;
|
const firstKey = pendingKeyRef.current;
|
||||||
pendingKeyRef.current = null;
|
pendingKeyRef.current = null;
|
||||||
if (pendingTimerRef.current) {
|
if (pendingTimerRef.current) {
|
||||||
clearTimeout(pendingTimerRef.current);
|
clearTimeout(pendingTimerRef.current);
|
||||||
@@ -92,25 +84,22 @@ export function KeyboardProvider({ children }: { children: React.ReactNode }) {
|
|||||||
for (const def of shortcuts.values()) {
|
for (const def of shortcuts.values()) {
|
||||||
if (
|
if (
|
||||||
def.keys.length === 2 &&
|
def.keys.length === 2 &&
|
||||||
def.keys[0] === comboKey.split("+")[0] &&
|
def.keys[0] === firstKey &&
|
||||||
def.keys[1] === comboKey.split("+")[1]
|
def.keys[1] === e.key
|
||||||
) {
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
def.action();
|
def.action();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no combo matched, fall through to single-key check
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for single-key shortcuts
|
|
||||||
for (const def of shortcuts.values()) {
|
for (const def of shortcuts.values()) {
|
||||||
if (def.keys.length === 1 && def.keys[0] === e.key) {
|
if (def.keys.length === 1 && def.keys[0] === e.key) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
def.action();
|
def.action();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Start combo sequence
|
|
||||||
if (def.keys.length === 2 && def.keys[0] === e.key) {
|
if (def.keys.length === 2 && def.keys[0] === e.key) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
pendingKeyRef.current = e.key;
|
pendingKeyRef.current = e.key;
|
||||||
@@ -124,12 +113,12 @@ export function KeyboardProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [shortcuts, commandPaletteOpen]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardContext.Provider
|
<KeyboardContext.Provider
|
||||||
value={{
|
value={{
|
||||||
shortcuts,
|
shortcutsRef,
|
||||||
registerShortcut,
|
registerShortcut,
|
||||||
unregisterShortcut,
|
unregisterShortcut,
|
||||||
commandPaletteOpen,
|
commandPaletteOpen,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Kbd } from "@/components/ui/kbd";
|
|||||||
import { signOut, useSession } from "@/lib/auth/client";
|
import { signOut, useSession } from "@/lib/auth/client";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: "/", label: "Home" },
|
{ href: "/dashboard", label: "Home" },
|
||||||
{ href: "/search", label: "Search" },
|
{ href: "/search", label: "Search" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -23,15 +23,18 @@ export function NavBar() {
|
|||||||
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
|
<header className="sticky top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
|
||||||
<nav className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
|
<nav className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<Link href="/" className="font-display text-xl tracking-tight">
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="font-display text-xl tracking-tight"
|
||||||
|
>
|
||||||
Couch Potato
|
Couch Potato
|
||||||
</Link>
|
</Link>
|
||||||
{session?.user && (
|
{session?.user && (
|
||||||
<div className="hidden items-center gap-1 sm:flex">
|
<div className="hidden items-center gap-1 sm:flex">
|
||||||
{navLinks.map((link) => {
|
{navLinks.map((link) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
link.href === "/"
|
link.href === "/dashboard"
|
||||||
? pathname === "/"
|
? pathname === "/dashboard"
|
||||||
: pathname.startsWith(link.href);
|
: pathname.startsWith(link.href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import { type ShortcutDef, useKeyboard } from "@/components/keyboard-provider";
|
import { type ShortcutDef, useKeyboard } from "@/components/keyboard-provider";
|
||||||
|
|
||||||
export function useRegisterShortcut(id: string, def: ShortcutDef) {
|
export function useRegisterShortcut(id: string, def: ShortcutDef) {
|
||||||
const { registerShortcut, unregisterShortcut } = useKeyboard();
|
const { registerShortcut, unregisterShortcut } = useKeyboard();
|
||||||
const keysKey = def.keys.join(",");
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: stable memoization on keys/description
|
|
||||||
const stableDef = useMemo(() => def, [keysKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerShortcut(id, stableDef);
|
registerShortcut(id, def);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
return () => unregisterShortcut(id);
|
return () => unregisterShortcut(id);
|
||||||
}, [id, registerShortcut, unregisterShortcut, stableDef]);
|
}, [id, unregisterShortcut]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db } from "@/lib/db/client";
|
|||||||
import {
|
import {
|
||||||
episodes,
|
episodes,
|
||||||
seasons,
|
seasons,
|
||||||
|
titles,
|
||||||
userEpisodeWatches,
|
userEpisodeWatches,
|
||||||
userMovieWatches,
|
userMovieWatches,
|
||||||
userRatings,
|
userRatings,
|
||||||
@@ -22,6 +23,10 @@ export function setTitleStatus(
|
|||||||
set: { status, updatedAt: now },
|
set: { status, updatedAt: now },
|
||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
markAllEpisodesWatched(userId, titleId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeTitleStatus(userId: string, titleId: string) {
|
export function removeTitleStatus(userId: string, titleId: string) {
|
||||||
@@ -97,6 +102,49 @@ export function logEpisodeWatch(userId: string, episodeId: string) {
|
|||||||
checkAllEpisodesWatched(userId, titleId);
|
checkAllEpisodesWatched(userId, titleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markAllEpisodesWatched(userId: string, titleId: string) {
|
||||||
|
const title = db.select().from(titles).where(eq(titles.id, titleId)).get();
|
||||||
|
if (!title || title.type !== "tv") return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const allSeasons = db
|
||||||
|
.select()
|
||||||
|
.from(seasons)
|
||||||
|
.where(eq(seasons.titleId, titleId))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const s of allSeasons) {
|
||||||
|
const eps = db
|
||||||
|
.select()
|
||||||
|
.from(episodes)
|
||||||
|
.where(eq(episodes.seasonId, s.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
for (const ep of eps) {
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(userEpisodeWatches)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userEpisodeWatches.userId, userId),
|
||||||
|
eq(userEpisodeWatches.episodeId, ep.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!existing) {
|
||||||
|
db.insert(userEpisodeWatches)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
episodeId: ep.id,
|
||||||
|
watchedAt: now,
|
||||||
|
source: "manual",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
function checkAllEpisodesWatched(userId: string, titleId: string) {
|
||||||
const allSeasons = db
|
const allSeasons = db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-hotkeys-hook": "^5.2.4",
|
||||||
"react-resizable-panels": "^4.6.5",
|
"react-resizable-panels": "^4.6.5",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"shadcn": "^3.8.5",
|
"shadcn": "^3.8.5",
|
||||||
|
|||||||
Generated
+14
@@ -53,6 +53,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@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:
|
react-resizable-panels:
|
||||||
specifier: ^4.6.5
|
specifier: ^4.6.5
|
||||||
version: 4.6.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 4.6.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -2741,6 +2744,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.4
|
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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -5425,6 +5434,11 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
scheduler: 0.27.0
|
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@16.13.1: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user