(
data-disabled={disabled}
data-active={isActive}
className="w-full overflow-visible border-b-[0.5px] border-transparent outline-none data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]"
- onKeyDown={e => {
- if (e.key === "Enter") {
- e.preventDefault()
- onItemSelected?.(personalLink)
- }
- }}
+ onKeyDown={handleKeyDown}
>
{
const isTablet = useMedia("(max-width: 640px)")
diff --git a/web/components/routes/page/list.tsx b/web/components/routes/page/list.tsx
index 4afc59f5..cd085729 100644
--- a/web/components/routes/page/list.tsx
+++ b/web/components/routes/page/list.tsx
@@ -4,7 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider"
import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { PageItem } from "./partials/page-item"
-import { useMedia } from "react-use"
+import { useMedia } from "@/hooks/use-media"
import { useColumnStyles } from "./hooks/use-column-styles"
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
import { useRouter } from "next/navigation"
diff --git a/web/components/routes/page/partials/page-item.tsx b/web/components/routes/page/partials/page-item.tsx
index 002a01e1..5a0d1af1 100644
--- a/web/components/routes/page/partials/page-item.tsx
+++ b/web/components/routes/page/partials/page-item.tsx
@@ -3,7 +3,7 @@ import Link from "next/link"
import { cn } from "@/lib/utils"
import { PersonalPage } from "@/lib/schema"
import { Badge } from "@/components/ui/badge"
-import { useMedia } from "react-use"
+import { useMedia } from "@/hooks/use-media"
import { useColumnStyles } from "../hooks/use-column-styles"
import { format } from "date-fns"
import { Column } from "@/components/custom/column"
diff --git a/web/components/routes/public/Autocomplete.tsx b/web/components/routes/public/Autocomplete.tsx
index 6cb80f60..25e49199 100644
--- a/web/components/routes/public/Autocomplete.tsx
+++ b/web/components/routes/public/Autocomplete.tsx
@@ -1,9 +1,9 @@
-import React, { useState, useRef, useCallback, useMemo, useEffect } from "react"
+import * as React from "react"
import { Command, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
import { Command as CommandPrimitive } from "cmdk"
import { motion, AnimatePresence } from "framer-motion"
import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils"
-import { useMountedState } from "react-use"
+import { useIsMounted } from "@/hooks/use-is-mounted"
interface GraphNode {
name: string
@@ -18,16 +18,16 @@ interface AutocompleteProps {
}
export function Autocomplete({ topics = [], onSelect, onInputChange }: AutocompleteProps): JSX.Element {
- const inputRef = useRef(null)
- const [open, setOpen] = useState(false)
- const isMounted = useMountedState()
- const [inputValue, setInputValue] = useState("")
- const [hasInteracted, setHasInteracted] = useState(false)
- const [showDropdown, setShowDropdown] = useState(false)
+ const inputRef = React.useRef(null)
+ const [, setOpen] = React.useState(false)
+ const isMounted = useIsMounted()
+ const [inputValue, setInputValue] = React.useState("")
+ const [hasInteracted, setHasInteracted] = React.useState(false)
+ const [showDropdown, setShowDropdown] = React.useState(false)
- const initialShuffledTopics = useMemo(() => shuffleArray(topics).slice(0, 5), [topics])
+ const initialShuffledTopics = React.useMemo(() => shuffleArray(topics).slice(0, 5), [topics])
- const filteredTopics = useMemo(() => {
+ const filteredTopics = React.useMemo(() => {
if (!inputValue) {
return initialShuffledTopics
}
@@ -44,7 +44,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
.slice(0, 10)
}, [inputValue, topics, initialShuffledTopics])
- const handleSelect = useCallback(
+ const handleSelect = React.useCallback(
(topic: GraphNode) => {
setOpen(false)
onSelect(topic.name)
@@ -52,7 +52,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
[onSelect]
)
- const handleInputChange = useCallback(
+ const handleInputChange = React.useCallback(
(value: string) => {
setInputValue(value)
setShowDropdown(true)
@@ -62,34 +62,27 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
[onInputChange]
)
- const handleFocus = useCallback(() => {
+ const handleFocus = React.useCallback(() => {
setHasInteracted(true)
}, [])
- const handleClick = useCallback(() => {
+ const handleClick = React.useCallback(() => {
setShowDropdown(true)
setHasInteracted(true)
}, [])
- const commandKey = useMemo(() => {
+ const commandKey = React.useMemo(() => {
return filteredTopics
.map(topic => `${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`)
.join("__")
}, [filteredTopics])
- useEffect(() => {
+ React.useEffect(() => {
if (inputRef.current && isMounted() && hasInteracted) {
inputRef.current.focus()
}
}, [commandKey, isMounted, hasInteracted])
- const animationProps = {
- initial: { opacity: 0, y: -10 },
- animate: { opacity: 1, y: 0 },
- exit: { opacity: 0, y: -10 },
- transition: { duration: 0.1 }
- }
-
return (
diff --git a/web/components/routes/topics/detail/Header.tsx b/web/components/routes/topics/detail/Header.tsx
index 0987e9a0..1349271b 100644
--- a/web/components/routes/topics/detail/Header.tsx
+++ b/web/components/routes/topics/detail/Header.tsx
@@ -8,6 +8,7 @@ import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { LearningStateValue } from "@/lib/constants"
import { useClerk } from "@clerk/nextjs"
import { usePathname } from "next/navigation"
+import { useMedia } from "@/hooks/use-media"
interface TopicDetailHeaderProps {
topic: Topic
@@ -16,6 +17,7 @@ interface TopicDetailHeaderProps {
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
const clerk = useClerk()
const pathname = usePathname()
+ const isMobile = useMedia("(max-width: 770px)")
const { me } = useAccountOrGuest({
root: {
topicsWantToLearn: [],
@@ -90,20 +92,19 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }
return (
-
+
-
-
{topic.prettyName}
+
+
{topic.prettyName}
-
-
)
diff --git a/web/components/routes/topics/detail/partials/link-item.tsx b/web/components/routes/topics/detail/partials/link-item.tsx
index 77964333..b232b425 100644
--- a/web/components/routes/topics/detail/partials/link-item.tsx
+++ b/web/components/routes/topics/detail/partials/link-item.tsx
@@ -151,12 +151,7 @@ export const LinkItem = React.memo(
)}
-
e.preventDefault()}
- >
+
{
const isTablet = useMedia("(max-width: 640px)")
diff --git a/web/components/routes/topics/list.tsx b/web/components/routes/topics/list.tsx
index 09f6c488..4d951498 100644
--- a/web/components/routes/topics/list.tsx
+++ b/web/components/routes/topics/list.tsx
@@ -4,7 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider"
import { atom, useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { TopicItem } from "./partials/topic-item"
-import { useMedia } from "react-use"
+import { useMedia } from "@/hooks/use-media"
import { useRouter } from "next/navigation"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { Column } from "@/components/custom/column"
diff --git a/web/components/routes/topics/partials/topic-item.tsx b/web/components/routes/topics/partials/topic-item.tsx
index ffef13d8..05aec78d 100644
--- a/web/components/routes/topics/partials/topic-item.tsx
+++ b/web/components/routes/topics/partials/topic-item.tsx
@@ -139,7 +139,6 @@ export const TopicItem = React.forwardRef(({ top
side="bottom"
align="end"
onClick={e => e.stopPropagation()}
- onCloseAutoFocus={e => e.preventDefault()}
>
(options: ActiveItemSc
const { activeIndex } = options
const elementRefs = useRef>([])
- const scrollActiveElementIntoView = useCallback((index: number) => {
+ const scrollActiveElementIntoView = (index: number) => {
const activeElement = elementRefs.current[index]
activeElement?.focus()
// activeElement?.scrollIntoView({ block: "nearest" })
- }, [])
+ }
useEffect(() => {
if (activeIndex !== null) {
diff --git a/web/hooks/use-event-listener.ts b/web/hooks/use-event-listener.ts
new file mode 100644
index 00000000..6bb50a22
--- /dev/null
+++ b/web/hooks/use-event-listener.ts
@@ -0,0 +1,33 @@
+import * as React from "react"
+
+type EventMap = WindowEventMap & HTMLElementEventMap & VisualViewportEventMap
+
+export function useEventListener<
+ K extends keyof EventMap,
+ T extends Window | HTMLElement | VisualViewport | null = Window
+>(
+ eventName: K,
+ handler: (event: EventMap[K]) => void,
+ element: T = window as unknown as T, // Cast to `unknown` first, then `T`
+ options: AddEventListenerOptions = {}
+) {
+ const savedHandler = React.useRef<(event: EventMap[K]) => void>()
+ const { capture, passive, once } = options
+
+ React.useEffect(() => {
+ savedHandler.current = handler
+ }, [handler])
+
+ React.useEffect(() => {
+ const isSupported = element && element.addEventListener
+ if (!isSupported) return
+
+ const eventListener = (event: EventMap[K]) => savedHandler.current?.(event)
+
+ const opts = { capture, passive, once }
+ element.addEventListener(eventName, eventListener as EventListener, opts)
+ return () => {
+ element.removeEventListener(eventName, eventListener as EventListener, opts)
+ }
+ }, [eventName, element, capture, passive, once])
+}
diff --git a/web/hooks/use-is-mounted.ts b/web/hooks/use-is-mounted.ts
new file mode 100644
index 00000000..334b1a17
--- /dev/null
+++ b/web/hooks/use-is-mounted.ts
@@ -0,0 +1,19 @@
+import * as React from "react"
+
+/**
+ * Hook to check if component is still mounted
+ *
+ * @returns {boolean} true if the component is mounted, false otherwise
+ */
+export function useIsMounted() {
+ const isMounted = React.useRef(false)
+
+ React.useEffect(() => {
+ isMounted.current = true
+ return () => {
+ isMounted.current = false
+ }
+ }, [])
+
+ return React.useCallback(() => isMounted.current, [])
+}
diff --git a/web/hooks/use-key-down.ts b/web/hooks/use-key-down.ts
new file mode 100644
index 00000000..ac42fed6
--- /dev/null
+++ b/web/hooks/use-key-down.ts
@@ -0,0 +1,79 @@
+import { isModKey, isTextInput } from "@/lib/utils"
+import * as React from "react"
+
+type Callback = (event: KeyboardEvent) => void
+
+export type KeyFilter = ((event: KeyboardEvent) => boolean) | string
+
+export type Options = {
+ allowInInput?: boolean
+}
+
+type RegisteredCallback = {
+ callback: Callback
+ options?: Options
+}
+
+// Registered keyboard event callbacks
+let callbacks: RegisteredCallback[] = []
+
+// Track if IME input suggestions are open so we can ignore keydown shortcuts
+// in this case, they should never be triggered from mobile keyboards.
+let imeOpen = false
+
+// Based on implementation in react-use
+// https://github.com/streamich/react-use/blob/master/src/useKey.ts#L15-L22
+const createKeyPredicate = (keyFilter: KeyFilter) =>
+ typeof keyFilter === "function"
+ ? keyFilter
+ : typeof keyFilter === "string"
+ ? (event: KeyboardEvent) => event.key === keyFilter
+ : keyFilter
+ ? (_event: KeyboardEvent) => true
+ : (_event: KeyboardEvent) => false
+
+export function useKeyDown(key: KeyFilter, fn: Callback, options?: Options): void {
+ const predicate = createKeyPredicate(key)
+
+ React.useEffect(() => {
+ const handler = (event: KeyboardEvent) => {
+ if (predicate(event)) {
+ fn(event)
+ }
+ }
+
+ callbacks.push({
+ callback: handler,
+ options
+ })
+
+ return () => {
+ callbacks = callbacks.filter(cb => cb.callback !== handler)
+ }
+ }, [fn, predicate, options])
+}
+
+window.addEventListener("keydown", event => {
+ if (imeOpen) {
+ return
+ }
+
+ // reverse so that the last registered callbacks get executed first
+ for (const registered of callbacks.reverse()) {
+ if (event.defaultPrevented === true) {
+ break
+ }
+
+ if (!isTextInput(event.target as HTMLElement) || registered.options?.allowInInput || isModKey(event)) {
+ registered.callback(event)
+ }
+ }
+})
+
+window.addEventListener("compositionstart", () => {
+ imeOpen = true
+})
+
+window.addEventListener("compositionend", () => {
+ imeOpen = false
+})
diff --git a/web/hooks/use-keyboard-manager.ts b/web/hooks/use-keyboard-manager.ts
index f73d0994..076f483d 100644
--- a/web/hooks/use-keyboard-manager.ts
+++ b/web/hooks/use-keyboard-manager.ts
@@ -2,7 +2,18 @@ import { useAtom } from "jotai"
import { useEffect, useCallback } from "react"
import { keyboardDisableSourcesAtom } from "@/store/keydown-manager"
-const allowedKeys = ["Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"]
+const allowedKeys = [
+ "Escape",
+ "ArrowUp",
+ "ArrowDown",
+ "ArrowLeft",
+ "ArrowRight",
+ "Enter",
+ "Tab",
+ "Backspace",
+ "Home",
+ "End"
+]
export function useKeyboardManager(sourceId: string) {
const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom)
diff --git a/web/hooks/use-keydown-listener.ts b/web/hooks/use-keydown-listener.ts
deleted file mode 100644
index 888f140e..00000000
--- a/web/hooks/use-keydown-listener.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useAtomValue } from "jotai"
-import { useEffect, useCallback } from "react"
-import { keyboardDisableSourcesAtom } from "@/store/keydown-manager"
-
-export function useKeydownListener(callback: (event: KeyboardEvent) => void) {
- const disableSources = useAtomValue(keyboardDisableSourcesAtom)
-
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (disableSources.size === 0) {
- callback(event)
- }
- },
- [disableSources, callback]
- )
-
- useEffect(() => {
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [handleKeyDown])
-}
diff --git a/web/hooks/use-media.ts b/web/hooks/use-media.ts
new file mode 100644
index 00000000..6f68e6bf
--- /dev/null
+++ b/web/hooks/use-media.ts
@@ -0,0 +1,23 @@
+import { useState, useEffect } from "react"
+
+export function useMedia(query: string): boolean {
+ const [matches, setMatches] = useState(false)
+
+ useEffect(() => {
+ if (window.matchMedia) {
+ const media = window.matchMedia(query)
+ if (media.matches !== matches) {
+ setMatches(media.matches)
+ }
+ const listener = () => {
+ setMatches(media.matches)
+ }
+ media.addListener(listener)
+ return () => media.removeListener(listener)
+ }
+
+ return undefined
+ }, [matches, query])
+
+ return matches
+}
diff --git a/web/hooks/use-on-click-outside.ts b/web/hooks/use-on-click-outside.ts
new file mode 100644
index 00000000..faed4679
--- /dev/null
+++ b/web/hooks/use-on-click-outside.ts
@@ -0,0 +1,28 @@
+import * as React from "react"
+import { useEventListener } from "./use-event-listener"
+
+/**
+ * Hook to detect clicks outside of a specified element.
+ *
+ * @param ref The React ref to the element.
+ * @param callback The handler to call when a click outside the element is detected.
+ */
+export function useOnClickOutside(
+ ref: React.RefObject,
+ callback?: (event: MouseEvent | TouchEvent) => void,
+ options: AddEventListenerOptions = {}
+) {
+ const listener = React.useCallback(
+ (event: MouseEvent | TouchEvent) => {
+ // Do nothing if clicking ref's element or descendent elements
+ if (!ref.current || ref.current.contains(event.target as Node)) {
+ return
+ }
+ callback?.(event)
+ },
+ [ref, callback]
+ )
+
+ useEventListener("mousedown", listener, window, options)
+ useEventListener("touchstart", listener, window, options)
+}
diff --git a/web/hooks/use-throttle.ts b/web/hooks/use-throttle.ts
new file mode 100644
index 00000000..7b58a4da
--- /dev/null
+++ b/web/hooks/use-throttle.ts
@@ -0,0 +1,34 @@
+import { useRef, useCallback } from "react"
+
+export function useThrottle void>(
+ callback: T,
+ delay: number
+): (...args: Parameters) => void {
+ const lastRan = useRef(Date.now())
+ const timeoutRef = useRef(null)
+
+ return useCallback(
+ (...args: Parameters) => {
+ const handler = () => {
+ if (Date.now() - lastRan.current >= delay) {
+ callback(...args)
+ lastRan.current = Date.now()
+ } else {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ timeoutRef.current = setTimeout(
+ () => {
+ callback(...args)
+ lastRan.current = Date.now()
+ },
+ delay - (Date.now() - lastRan.current)
+ )
+ }
+ }
+
+ handler()
+ },
+ [callback, delay]
+ )
+}
diff --git a/web/hooks/use-touch-sensor.ts b/web/hooks/use-touch-sensor.ts
index 437aff41..c0fa78ba 100644
--- a/web/hooks/use-touch-sensor.ts
+++ b/web/hooks/use-touch-sensor.ts
@@ -1,11 +1,18 @@
import { useState, useEffect } from "react"
+const SSR = typeof window === "undefined"
+
export function useTouchSensor() {
const [isTouchDevice, setIsTouchDevice] = useState(false)
useEffect(() => {
const detectTouch = () => {
- setIsTouchDevice("ontouchstart" in window || navigator.maxTouchPoints > 0)
+ setIsTouchDevice(
+ !SSR &&
+ (window.matchMedia?.("(hover: none) and (pointer: coarse)")?.matches ||
+ "ontouchstart" in window ||
+ navigator.maxTouchPoints > 0)
+ )
}
detectTouch()
diff --git a/web/lib/utils/index.ts b/web/lib/utils/index.ts
index 9be23460..43f9bd26 100644
--- a/web/lib/utils/index.ts
+++ b/web/lib/utils/index.ts
@@ -34,22 +34,16 @@ export function shuffleArray(array: T[]): T[] {
return shuffled
}
-export const isEditableElement = (element: HTMLElement): boolean => {
- if (element.isContentEditable) {
- return true
- }
+const inputs = ["input", "select", "button", "textarea"] // detect if node is a text input element
- const tagName = element.tagName.toLowerCase()
- const editableTags = ["input", "textarea", "select", "option"]
-
- if (editableTags.includes(tagName)) {
- return true
- }
-
- const role = element.getAttribute("role")
- const editableRoles = ["textbox", "combobox", "listbox"]
-
- return role ? editableRoles.includes(role) : false
+export function isTextInput(element: Element): boolean {
+ return !!(
+ element &&
+ element.tagName &&
+ (inputs.indexOf(element.tagName.toLowerCase()) !== -1 ||
+ element.attributes.getNamedItem("role")?.value === "textbox" ||
+ element.attributes.getNamedItem("contenteditable")?.value === "true")
+ )
}
export * from "./urls"
diff --git a/web/lib/utils/keyboard.ts b/web/lib/utils/keyboard.ts
index 50255db7..27e8c208 100644
--- a/web/lib/utils/keyboard.ts
+++ b/web/lib/utils/keyboard.ts
@@ -1,47 +1,4 @@
-let isMac: boolean | undefined
-
-interface Navigator {
- userAgentData?: {
- brands: { brand: string; version: string }[]
- mobile: boolean
- platform: string
- getHighEntropyValues: (hints: string[]) => Promise<{
- platform: string
- platformVersion: string
- uaFullVersion: string
- }>
- }
-}
-
-function getPlatform(): string {
- const nav = navigator as Navigator
-
- if (nav.userAgentData) {
- if (nav.userAgentData.platform) {
- return nav.userAgentData.platform
- }
-
- nav.userAgentData.getHighEntropyValues(["platform"]).then(highEntropyValues => {
- if (highEntropyValues.platform) {
- return highEntropyValues.platform
- }
- })
- }
-
- if (typeof navigator.platform === "string") {
- return navigator.platform
- }
-
- return ""
-}
-
-export function isMacOS() {
- if (isMac === undefined) {
- isMac = getPlatform().toLowerCase().includes("mac")
- }
-
- return isMac
-}
+const SSR = typeof window === "undefined"
interface ShortcutKeyResult {
symbol: string
@@ -51,11 +8,11 @@ interface ShortcutKeyResult {
export function getShortcutKey(key: string): ShortcutKeyResult {
const lowercaseKey = key.toLowerCase()
if (lowercaseKey === "mod") {
- return isMacOS() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" }
+ return isMac() ? { symbol: "⌘", readable: "Command" } : { symbol: "Ctrl", readable: "Control" }
} else if (lowercaseKey === "alt") {
- return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
+ return isMac() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
} else if (lowercaseKey === "shift") {
- return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" }
+ return isMac() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" }
} else {
return { symbol: key.toUpperCase(), readable: key }
}
@@ -64,3 +21,39 @@ export function getShortcutKey(key: string): ShortcutKeyResult {
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
return keys.map(key => getShortcutKey(key))
}
+
+export function isModKey(event: KeyboardEvent | MouseEvent | React.KeyboardEvent) {
+ return isMac() ? event.metaKey : event.ctrlKey
+}
+
+export function isMac(): boolean {
+ if (SSR) {
+ return false
+ }
+ return window.navigator.platform === "MacIntel"
+}
+
+export function isWindows(): boolean {
+ if (SSR) {
+ return false
+ }
+ return window.navigator.platform === "Win32"
+}
+
+let supportsPassive = false
+
+try {
+ const opts = Object.defineProperty({}, "passive", {
+ get() {
+ supportsPassive = true
+ }
+ })
+ // @ts-expect-error ts-migrate(2769) testPassive is not a real event
+ window.addEventListener("testPassive", null, opts)
+ // @ts-expect-error ts-migrate(2769) testPassive is not a real event
+ window.removeEventListener("testPassive", null, opts)
+} catch (e) {
+ // No-op
+}
+
+export const supportsPassiveListener = supportsPassive
diff --git a/web/package.json b/web/package.json
index 619a7b73..0135be5a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -78,22 +78,22 @@
"date-fns": "^3.6.0",
"framer-motion": "^11.5.6",
"geist": "^1.3.1",
- "jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
- "jazz-react": "0.7.35-guest-auth.5",
- "jazz-react-auth-clerk": "0.7.35-guest-auth.5",
- "jazz-tools": "0.7.35-guest-auth.5",
+ "jazz-browser-auth-clerk": "0.8.0",
+ "jazz-react": "0.8.0",
+ "jazz-react-auth-clerk": "0.8.0",
+ "jazz-tools": "0.8.0",
"jotai": "^2.10.0",
"lowlight": "^3.1.0",
"lucide-react": "^0.429.0",
"next": "14.2.10",
"next-themes": "^0.3.0",
"nuqs": "^1.19.1",
+ "query-string": "^9.1.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-textarea-autosize": "^8.5.3",
- "react-use": "^17.5.1",
"ronin": "^4.3.1",
"slugify": "^1.6.6",
"sonner": "^1.5.0",