mirror of
https://github.com/linsa-io/linsa.git
synced 2026-03-17 23:03:55 +01:00
fix: Link, Pages, Topic, Hook and Others (#178)
* chore: remove useKeyDownListener * chore: remove react-use, update jazz version and add query string * chore: update jazz version * chore: use simple mac or win utils code * feat(util): add isTextInput * feat(hooks): all needed hooks * fix: link bunch stuff * fix: page bunch stuff * chore: bunch update for custom component * chore: use throttle from internal hook * chore: topic bunch stuff * chore: update layout * fix: truncate content header of topic detail
This commit is contained in:
@@ -17,11 +17,10 @@
|
||||
"@clerk/themes": "^2.1.30",
|
||||
"@tauri-apps/cli": "^2.0.0-rc.16",
|
||||
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
|
||||
"jazz-nodejs": "0.7.35-guest-auth.5",
|
||||
"react-icons": "^5.3.0"
|
||||
"jazz-nodejs": "0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^1.1.28"
|
||||
"bun-types": "^1.1.29"
|
||||
},
|
||||
"prettier": {
|
||||
"plugins": [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Sidebar } from "@/components/custom/sidebar/sidebar"
|
||||
import { CommandPalette } from "@/components/custom/command-palette/command-palette"
|
||||
import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
|
||||
import { Shortcut } from "@/components/custom/Shortcut/shortcut"
|
||||
import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler"
|
||||
import { GlobalKeyboardHandler } from "@/components/custom/global-keyboard-handler"
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width, shrink-to-fit=no",
|
||||
@@ -16,8 +16,7 @@ export default function PageLayout({ children }: { children: React.ReactNode })
|
||||
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
|
||||
<Sidebar />
|
||||
<LearnAnythingOnboarding />
|
||||
<GlobalKeydownHandler />
|
||||
|
||||
<GlobalKeyboardHandler />
|
||||
<CommandPalette />
|
||||
<Shortcut />
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ const SHORTCUTS: ShortcutSection[] = [
|
||||
{ label: "Go to page", keys: ["G"], then: ["P"] },
|
||||
{ label: "Go to topic", keys: ["G"], then: ["T"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Links",
|
||||
shortcuts: [{ label: "Create new link", keys: ["c"] }]
|
||||
},
|
||||
{
|
||||
title: "Pages",
|
||||
shortcuts: [{ label: "Create new page", keys: ["p"] }]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { searchSafeRegExp } from "@/lib/utils"
|
||||
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
|
||||
import { useCommandActions } from "./hooks/use-command-actions"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
|
||||
const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
|
||||
|
||||
@@ -40,18 +39,6 @@ export function RealCommandPalette() {
|
||||
|
||||
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
|
||||
|
||||
const handleKeydown = React.useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen(prev => !prev)
|
||||
}
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
useKeydownListener(handleKeydown)
|
||||
|
||||
const bounce = React.useCallback(() => {
|
||||
if (dialogRef.current) {
|
||||
dialogRef.current.style.transform = "scale(0.99) translateX(-50%)"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import * as React from "react"
|
||||
import { Button } from "../ui/button"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
import { useAtom } from "jotai"
|
||||
import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "./la-icon"
|
||||
|
||||
type ContentHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title">
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex min-h-10 min-w-0 max-w-[100vw] shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||
"flex min-h-10 min-w-0 shrink-0 items-center gap-3 pl-8 pr-6 transition-opacity max-lg:px-4",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -52,7 +52,7 @@ export const SidebarToggleButton: React.FC = () => {
|
||||
className="text-primary/60"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PanelLeftIcon size={16} />
|
||||
<LaIcon name="PanelLeft" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
130
web/components/custom/global-keyboard-handler.tsx
Normal file
130
web/components/custom/global-keyboard-handler.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { useRouter } from "next/navigation"
|
||||
import queryString from "query-string"
|
||||
import { usePageActions } from "../routes/page/hooks/use-page-actions"
|
||||
import { useAuth } from "@clerk/nextjs"
|
||||
import { isModKey } from "@/lib/utils"
|
||||
import { useAtom } from "jotai"
|
||||
import { commandPaletteOpenAtom } from "./command-palette/command-palette"
|
||||
|
||||
type RegisterKeyDownProps = {
|
||||
trigger: KeyFilter
|
||||
handler: (event: KeyboardEvent) => void
|
||||
options?: Options
|
||||
}
|
||||
|
||||
function RegisterKeyDown({ trigger, handler, options }: RegisterKeyDownProps) {
|
||||
useKeyDown(trigger, handler, options)
|
||||
return null
|
||||
}
|
||||
|
||||
type Sequence = {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
const SEQUENCES: Sequence = {
|
||||
GL: "/links",
|
||||
GP: "/pages",
|
||||
GT: "/topics"
|
||||
}
|
||||
|
||||
const MAX_SEQUENCE_TIME = 1000
|
||||
|
||||
export function GlobalKeyboardHandler() {
|
||||
const [openCommandPalette, setOpenCommandPalette] = useAtom(commandPaletteOpenAtom)
|
||||
const [sequence, setSequence] = useState<string[]>([])
|
||||
const { signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
const { me } = useAccountOrGuest()
|
||||
const { newPage } = usePageActions()
|
||||
|
||||
const resetSequence = useCallback(() => {
|
||||
setSequence([])
|
||||
}, [])
|
||||
|
||||
const checkSequence = useCallback(() => {
|
||||
const sequenceStr = sequence.join("")
|
||||
const route = SEQUENCES[sequenceStr]
|
||||
|
||||
if (route) {
|
||||
console.log(`Navigating to ${route}...`)
|
||||
router.push(route)
|
||||
resetSequence()
|
||||
}
|
||||
}, [sequence, router, resetSequence])
|
||||
|
||||
const goToNewLink = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/links?${queryString.stringify({ create: true })}`)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
const goToNewPage = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.altKey) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!me || me._type === "Anonymous") {
|
||||
return
|
||||
}
|
||||
|
||||
const page = newPage(me)
|
||||
|
||||
router.push(`/pages/${page.id}`)
|
||||
},
|
||||
[me, newPage, router]
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
e => e.altKey && e.shiftKey && e.code === "KeyQ",
|
||||
() => {
|
||||
signOut()
|
||||
}
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
() => true,
|
||||
e => {
|
||||
const key = e.key.toUpperCase()
|
||||
setSequence(prev => [...prev, key])
|
||||
}
|
||||
)
|
||||
|
||||
useKeyDown(
|
||||
e => isModKey(e) && e.code === "KeyK",
|
||||
e => {
|
||||
e.preventDefault()
|
||||
setOpenCommandPalette(prev => !prev)
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
checkSequence()
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resetSequence()
|
||||
}, MAX_SEQUENCE_TIME)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [sequence, checkSequence, resetSequence])
|
||||
|
||||
return (
|
||||
me &&
|
||||
me._type !== "Anonymous" && (
|
||||
<>
|
||||
<RegisterKeyDown trigger="c" handler={goToNewLink} />
|
||||
<RegisterKeyDown trigger="p" handler={goToNewPage} />
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
import { useAuth } from "@clerk/nextjs"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
type Sequence = {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
const SEQUENCES: Sequence = {
|
||||
GL: "/links",
|
||||
GP: "/pages",
|
||||
GT: "/topics"
|
||||
}
|
||||
|
||||
const MAX_SEQUENCE_TIME = 1000
|
||||
|
||||
export function GlobalKeydownHandler() {
|
||||
const [sequence, setSequence] = useState<string[]>([])
|
||||
const { signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const resetSequence = useCallback(() => {
|
||||
setSequence([])
|
||||
}, [])
|
||||
|
||||
const checkSequence = useCallback(() => {
|
||||
const sequenceStr = sequence.join("")
|
||||
const route = SEQUENCES[sequenceStr]
|
||||
|
||||
if (route) {
|
||||
console.log(`Navigating to ${route}...`)
|
||||
router.push(route)
|
||||
resetSequence()
|
||||
}
|
||||
}, [sequence, router, resetSequence])
|
||||
|
||||
useKeydownListener((e: KeyboardEvent) => {
|
||||
// Check for logout shortcut
|
||||
if (e.altKey && e.shiftKey && e.code === "KeyQ") {
|
||||
signOut()
|
||||
return
|
||||
}
|
||||
|
||||
// Key sequence handling
|
||||
const key = e.key.toUpperCase()
|
||||
setSequence(prev => [...prev, key])
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
checkSequence()
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resetSequence()
|
||||
}, MAX_SEQUENCE_TIME)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [sequence, checkSequence, resetSequence])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
|
||||
import { linkLearningStateSelectorAtom } from "@/store/link"
|
||||
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import type { icons } from "lucide-react"
|
||||
|
||||
interface LearningStateSelectorProps {
|
||||
showSearch?: boolean
|
||||
@@ -16,6 +17,7 @@ interface LearningStateSelectorProps {
|
||||
value?: string
|
||||
onChange: (value: LearningStateValue) => void
|
||||
className?: string
|
||||
defaultIcon?: keyof typeof icons
|
||||
}
|
||||
|
||||
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
@@ -24,7 +26,8 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
searchPlaceholder = "Search state...",
|
||||
value,
|
||||
onChange,
|
||||
className
|
||||
className,
|
||||
defaultIcon
|
||||
}) => {
|
||||
const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
|
||||
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value])
|
||||
@@ -44,21 +47,24 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
variant="secondary"
|
||||
className={cn("gap-x-2 text-sm", className)}
|
||||
>
|
||||
{selectedLearningState?.icon && (
|
||||
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
|
||||
)}
|
||||
<span className={cn("truncate", selectedLearningState?.className || "")}>
|
||||
{selectedLearningState?.label || defaultLabel}
|
||||
</span>
|
||||
{selectedLearningState?.icon ||
|
||||
(defaultIcon && (
|
||||
<LaIcon
|
||||
name={selectedLearningState?.icon || defaultIcon}
|
||||
className={cn(selectedLearningState?.className)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{selectedLearningState?.label ||
|
||||
(defaultLabel && (
|
||||
<span className={cn("truncate", selectedLearningState?.className || "")}>
|
||||
{selectedLearningState?.label || defaultLabel}
|
||||
</span>
|
||||
))}
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="end"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="end">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={showSearch}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
|
||||
@@ -81,10 +81,7 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActi
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/pages"
|
||||
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
|
||||
>
|
||||
<Link href="/pages" className="flex flex-1 items-center justify-start rounded-md px-2 py-1">
|
||||
<p className="text-xs">
|
||||
Pages
|
||||
{pageCount > 0 && <span className="text-muted-foreground ml-1">{pageCount}</span>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import * as React from "react"
|
||||
import { SignInButton, useAuth, useUser } from "@clerk/nextjs"
|
||||
import { useAtom } from "jotai"
|
||||
import Link from "next/link"
|
||||
@@ -27,13 +27,13 @@ import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
export const ProfileSection: React.FC = () => {
|
||||
const { user, isSignedIn } = useUser()
|
||||
const { signOut } = useAuth()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = React.useState(false)
|
||||
const pathname = usePathname()
|
||||
const [, setShowShortcut] = useAtom(showShortcutAtom)
|
||||
|
||||
const { disableKeydown } = useKeyboardManager("profileSection")
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
disableKeydown(menuOpen)
|
||||
}, [menuOpen, disableKeydown])
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { useAtom } from "jotai"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { Logo } from "@/components/custom/logo"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -15,6 +14,7 @@ import { PageSection } from "./partial/page-section"
|
||||
import { TopicSection } from "./partial/topic-section"
|
||||
import { ProfileSection } from "./partial/profile-section"
|
||||
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
|
||||
import { LaIcon } from "../la-icon"
|
||||
|
||||
interface SidebarContextType {
|
||||
isCollapsed: boolean
|
||||
@@ -98,7 +98,7 @@ const LogoAndSearch: React.FC = React.memo(() => {
|
||||
type="button"
|
||||
className="text-primary/60 flex w-20 items-center justify-start py-4 pl-2"
|
||||
>
|
||||
<SearchIcon size={16} className="mr-2" />
|
||||
<LaIcon name="Search" className="mr-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -119,11 +119,11 @@ const SidebarContent: React.FC = React.memo(() => {
|
||||
<div>
|
||||
<LogoAndSearch />
|
||||
</div>
|
||||
<div tabIndex={-1} className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3">
|
||||
<div className="relative mb-0.5 mt-1.5 flex grow flex-col overflow-y-auto rounded-md px-3 outline-none">
|
||||
<div className="h-2 shrink-0" />
|
||||
{me._type === "Account" && <LinkSection pathname={pathname} />}
|
||||
{me._type === "Account" && <PageSection pathname={pathname} />}
|
||||
{me._type === "Account" && <TopicSection pathname={pathname} />}
|
||||
{me._type === "Account" && <PageSection pathname={pathname} />}
|
||||
</div>
|
||||
|
||||
<ProfileSection />
|
||||
|
||||
@@ -79,12 +79,7 @@ export const TopicSelector = forwardRef<HTMLButtonElement, TopicSelectorProps>(
|
||||
<LaIcon name="ChevronDown" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side={side}
|
||||
align={align}
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side={side} align={align}>
|
||||
{group?.root.topics && (
|
||||
<TopicSelectorContent
|
||||
showSearch={showSearch}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import { EditorContent, useEditor } from "@tiptap/react"
|
||||
import { Editor, Content } from "@tiptap/core"
|
||||
import { useThrottleFn } from "react-use"
|
||||
import { BubbleMenu } from "./components/bubble-menu"
|
||||
import { createExtensions } from "./extensions"
|
||||
import "./styles/index.css"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getOutput } from "./lib/utils"
|
||||
import { EditorView } from "@tiptap/pm/view"
|
||||
import type { EditorView } from "@tiptap/pm/view"
|
||||
import { useThrottle } from "@/hooks/use-throttle"
|
||||
|
||||
export interface LAEditorProps extends Omit<React.HTMLProps<HTMLDivElement>, "value"> {
|
||||
output?: "html" | "json" | "text"
|
||||
@@ -25,10 +25,6 @@ export interface LAEditorRef {
|
||||
editor: Editor | null
|
||||
}
|
||||
|
||||
interface CustomEditor extends Editor {
|
||||
previousBlockCount?: number
|
||||
}
|
||||
|
||||
export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
||||
(
|
||||
{
|
||||
@@ -46,32 +42,13 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [content, setContent] = React.useState<Content | undefined>(value)
|
||||
const throttledContent = useThrottleFn(defaultContent => defaultContent, throttleDelay, [content])
|
||||
const [lastThrottledContent, setLastThrottledContent] = React.useState(throttledContent)
|
||||
const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay)
|
||||
|
||||
const handleUpdate = React.useCallback(
|
||||
(editor: Editor) => {
|
||||
const newContent = getOutput(editor, output)
|
||||
setContent(newContent)
|
||||
|
||||
const customEditor = editor as CustomEditor
|
||||
const json = customEditor.getJSON()
|
||||
|
||||
if (json.content && Array.isArray(json.content)) {
|
||||
const currentBlockCount = json.content.length
|
||||
|
||||
if (
|
||||
typeof customEditor.previousBlockCount === "number" &&
|
||||
currentBlockCount > customEditor.previousBlockCount
|
||||
) {
|
||||
onNewBlock?.(newContent)
|
||||
}
|
||||
|
||||
customEditor.previousBlockCount = currentBlockCount
|
||||
}
|
||||
throttledSetValue(getOutput(editor, output))
|
||||
},
|
||||
[output, onNewBlock]
|
||||
[output, throttledSetValue]
|
||||
)
|
||||
|
||||
const editor = useEditor({
|
||||
@@ -96,13 +73,6 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
|
||||
}
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (lastThrottledContent !== throttledContent) {
|
||||
setLastThrottledContent(throttledContent)
|
||||
onUpdate?.(throttledContent!)
|
||||
}
|
||||
}, [throttledContent, lastThrottledContent, onUpdate])
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState } from "react"
|
||||
import * as React from "react"
|
||||
import { LinkHeader } from "@/components/routes/link/header"
|
||||
import { LinkList } from "@/components/routes/link/list"
|
||||
import { LinkManage } from "@/components/routes/link/manage"
|
||||
import { useQueryState } from "nuqs"
|
||||
import { atom } from "jotai"
|
||||
import { LinkBottomBar } from "./bottom-bar"
|
||||
import { useKey } from "react-use"
|
||||
|
||||
export const isDeleteConfirmShownAtom = atom(false)
|
||||
|
||||
export function LinkRoute(): React.ReactElement {
|
||||
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
|
||||
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = useState<number | null>(null)
|
||||
|
||||
useKey("Escape", () => {
|
||||
setNuqsEditId(null)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkHeader />
|
||||
<LinkManage />
|
||||
<LinkList
|
||||
key={nuqsEditId}
|
||||
activeItemIndex={activeItemIndex}
|
||||
setActiveItemIndex={setActiveItemIndex}
|
||||
keyboardActiveIndex={keyboardActiveIndex}
|
||||
setKeyboardActiveIndex={setKeyboardActiveIndex}
|
||||
/>
|
||||
<LinkList />
|
||||
<LinkBottomBar />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import * as React from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import type { icons } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { cn, getShortcutKeys, isEditableElement } from "@/lib/utils"
|
||||
import { cn, getShortcutKeys } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
import { useAtom } from "jotai"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
@@ -15,7 +15,6 @@ import { PersonalLink } from "@/lib/schema"
|
||||
import { ID } from "jazz-tools"
|
||||
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
|
||||
import { useLinkActions } from "./hooks/use-link-actions"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
|
||||
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
|
||||
icon: keyof typeof icons
|
||||
@@ -55,27 +54,27 @@ export const LinkBottomBar: React.FC = () => {
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>)
|
||||
|
||||
const cancelBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const cancelBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const confirmBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null)
|
||||
const contentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const deleteBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const plusBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const plusMoreBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const deleteBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const editMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
const plusMoreBtnRef = React.useRef<HTMLButtonElement>(null)
|
||||
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const handleCreateMode = useCallback(() => {
|
||||
const handleCreateMode = React.useCallback(() => {
|
||||
setEditId(null)
|
||||
requestAnimationFrame(() => {
|
||||
setCreateMode(prev => !prev)
|
||||
})
|
||||
}, [setEditId, setCreateMode])
|
||||
|
||||
const exceptionRefs = useMemo(
|
||||
const exceptionRefs = React.useMemo(
|
||||
() => [
|
||||
overlayRef,
|
||||
contentRef,
|
||||
@@ -89,7 +88,7 @@ export const LinkBottomBar: React.FC = () => {
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
|
||||
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
|
||||
|
||||
@@ -124,21 +123,6 @@ export const LinkBottomBar: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
const isCreateShortcut = event.key === "c"
|
||||
const target = event.target as HTMLElement
|
||||
|
||||
if (isCreateShortcut && !isEditableElement(target)) {
|
||||
event.preventDefault()
|
||||
handleCreateMode()
|
||||
}
|
||||
},
|
||||
[handleCreateMode]
|
||||
)
|
||||
|
||||
useKeydownListener(handleKeydown)
|
||||
|
||||
const shortcutText = getShortcutKeys(["c"])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ListFilterIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
@@ -15,6 +14,7 @@ import { LEARNING_STATES } from "@/lib/constants"
|
||||
import { useQueryState, parseAsStringLiteral } from "nuqs"
|
||||
import { FancySwitch } from "@omit/react-fancy-switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
const ALL_STATES = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
|
||||
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
|
||||
@@ -116,7 +116,7 @@ const FilterAndSort = React.memo(() => {
|
||||
<Popover open={sortOpen} onOpenChange={setSortOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" type="button" variant="secondary" className="min-w-8 gap-x-2 text-sm max-sm:p-0">
|
||||
<ListFilterIcon size={16} className="text-primary/60" />
|
||||
<LaIcon name="ListFilter" className="text-primary/60" />
|
||||
<span className="hidden md:block">Filter: {getFilterText()}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
import * as React from "react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -19,25 +19,18 @@ import { useAccount } from "@/lib/providers/jazz-provider"
|
||||
import { PersonalLinkLists } from "@/lib/schema/personal-link"
|
||||
import { useAtom } from "jotai"
|
||||
import { linkSortAtom } from "@/store/link"
|
||||
import { useKey } from "react-use"
|
||||
import { LinkItem } from "./partials/link-item"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
import { learningStateAtom } from "./header"
|
||||
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
|
||||
import { useConfirm } from "@omit/react-confirm-dialog"
|
||||
import { useLinkActions } from "./hooks/use-link-actions"
|
||||
import { isDeleteConfirmShownAtom } from "./LinkRoute"
|
||||
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
|
||||
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
|
||||
import { useKeydownListener } from "@/hooks/use-keydown-listener"
|
||||
import { useTouchSensor } from "@/hooks/use-touch-sensor"
|
||||
import { useKeyDown } from "@/hooks/use-key-down"
|
||||
import { isModKey } from "@/lib/utils"
|
||||
|
||||
interface LinkListProps {
|
||||
activeItemIndex: number | null
|
||||
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
|
||||
keyboardActiveIndex: number | null
|
||||
setKeyboardActiveIndex: React.Dispatch<React.SetStateAction<number | null>>
|
||||
}
|
||||
interface LinkListProps {}
|
||||
|
||||
const measuring: MeasuringConfiguration = {
|
||||
droppable: {
|
||||
@@ -45,14 +38,11 @@ const measuring: MeasuringConfiguration = {
|
||||
}
|
||||
}
|
||||
|
||||
const LinkList: React.FC<LinkListProps> = ({
|
||||
activeItemIndex,
|
||||
setActiveItemIndex,
|
||||
keyboardActiveIndex,
|
||||
setKeyboardActiveIndex
|
||||
}) => {
|
||||
const LinkList: React.FC<LinkListProps> = () => {
|
||||
const isTouchDevice = useTouchSensor()
|
||||
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
|
||||
const lastActiveIndexRef = React.useRef<number | null>(null)
|
||||
const [activeItemIndex, setActiveItemIndex] = React.useState<number | null>(null)
|
||||
const [keyboardActiveIndex, setKeyboardActiveIndex] = React.useState<number | null>(null)
|
||||
const [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
|
||||
const [editId, setEditId] = useQueryState("editId")
|
||||
const [createMode] = useQueryState("create", parseAsBoolean)
|
||||
@@ -63,11 +53,10 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
const { deleteLink } = useLinkActions()
|
||||
const confirm = useConfirm()
|
||||
const { me } = useAccount({ root: { personalLinks: [] } })
|
||||
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
|
||||
|
||||
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
||||
const personalLinks = React.useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
|
||||
|
||||
const filteredLinks = useMemo(
|
||||
const filteredLinks = React.useMemo(
|
||||
() =>
|
||||
personalLinks.filter(link => {
|
||||
if (activeLearningState === "all") return true
|
||||
@@ -77,7 +66,7 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
[personalLinks, activeLearningState]
|
||||
)
|
||||
|
||||
const sortedLinks = useMemo(
|
||||
const sortedLinks = React.useMemo(
|
||||
() =>
|
||||
sort === "title"
|
||||
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
|
||||
@@ -85,9 +74,21 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
[filteredLinks, sort]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editId !== null) {
|
||||
const index = sortedLinks.findIndex(link => link?.id === editId)
|
||||
if (index !== -1) {
|
||||
lastActiveIndexRef.current = index
|
||||
setActiveItemIndex(index)
|
||||
setKeyboardActiveIndex(index)
|
||||
}
|
||||
}
|
||||
}, [editId, setActiveItemIndex, setKeyboardActiveIndex, sortedLinks])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
|
||||
activationConstraint: {
|
||||
...(isTouchDevice ? { delay: 100, tolerance: 5 } : {}),
|
||||
distance: 5
|
||||
}
|
||||
}),
|
||||
@@ -96,7 +97,7 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
})
|
||||
)
|
||||
|
||||
const updateSequences = useCallback((links: PersonalLinkLists) => {
|
||||
const updateSequences = React.useCallback((links: PersonalLinkLists) => {
|
||||
links.forEach((link, index) => {
|
||||
if (link) {
|
||||
link.sequence = index
|
||||
@@ -104,7 +105,7 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDeleteLink = useCallback(async () => {
|
||||
const handleDeleteLink = React.useCallback(async () => {
|
||||
if (activeItemIndex === null) return
|
||||
setIsDeleteConfirmShown(true)
|
||||
const activeLink = sortedLinks[activeItemIndex]
|
||||
@@ -124,63 +125,31 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
setIsDeleteConfirmShown(false)
|
||||
}, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown])
|
||||
|
||||
useKey(event => (event.metaKey || event.ctrlKey) && event.key === "Backspace", handleDeleteLink, { event: "keydown" })
|
||||
useKeyDown(e => isModKey(e) && e.key === "Backspace", handleDeleteLink)
|
||||
|
||||
useKeydownListener((e: KeyboardEvent) => {
|
||||
if (
|
||||
isKeyboardDisabled ||
|
||||
isCommandPaletteOpen ||
|
||||
!me?.root?.personalLinks ||
|
||||
sortedLinks.length === 0 ||
|
||||
editId !== null ||
|
||||
e.defaultPrevented
|
||||
)
|
||||
return
|
||||
const next = () => Math.min((activeItemIndex ?? 0) + 1, sortedLinks.length - 1)
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(prevIndex => {
|
||||
if (prevIndex === null) return 0
|
||||
|
||||
const newIndex =
|
||||
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
|
||||
|
||||
if (e.metaKey && sort === "manual") {
|
||||
const linksArray = [...me.root.personalLinks]
|
||||
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
|
||||
|
||||
while (me.root.personalLinks.length > 0) {
|
||||
me.root.personalLinks.pop()
|
||||
}
|
||||
|
||||
newLinks.forEach(link => {
|
||||
if (link) {
|
||||
me.root.personalLinks.push(link)
|
||||
}
|
||||
})
|
||||
|
||||
updateSequences(me.root.personalLinks)
|
||||
}
|
||||
|
||||
setKeyboardActiveIndex(newIndex)
|
||||
|
||||
return newIndex
|
||||
})
|
||||
break
|
||||
case "Home":
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(0)
|
||||
break
|
||||
case "End":
|
||||
e.preventDefault()
|
||||
setActiveItemIndex(sortedLinks.length - 1)
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(next())
|
||||
setKeyboardActiveIndex(next())
|
||||
break
|
||||
case "ArrowUp":
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
setActiveItemIndex(prev())
|
||||
setKeyboardActiveIndex(prev())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
useKeyDown(() => true, handleKeyDown)
|
||||
|
||||
const handleDragStart = React.useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (sort !== "manual") return
|
||||
if (!me) return
|
||||
@@ -199,7 +168,7 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
[sort, me, setActiveItemIndex]
|
||||
)
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
const handleDragCancel = React.useCallback(() => {
|
||||
setDraggingId(null)
|
||||
}, [])
|
||||
|
||||
@@ -249,7 +218,9 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
setDraggingId(null)
|
||||
}
|
||||
|
||||
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: keyboardActiveIndex })
|
||||
const { setElementRef } = useActiveItemScroll<HTMLDivElement>({
|
||||
activeIndex: keyboardActiveIndex
|
||||
})
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
@@ -261,7 +232,7 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
measuring={measuring}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<div className="relative flex h-full grow items-stretch overflow-hidden">
|
||||
<div className="relative flex h-full grow items-stretch overflow-hidden" tabIndex={-1}>
|
||||
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
|
||||
<div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
|
||||
<div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
|
||||
@@ -274,9 +245,7 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
isActive={activeItemIndex === index}
|
||||
personalLink={linkItem}
|
||||
editId={editId}
|
||||
setEditId={setEditId}
|
||||
disabled={sort !== "manual" || editId !== null}
|
||||
setActiveItemIndex={setActiveItemIndex}
|
||||
onPointerMove={() => {
|
||||
if (editId !== null || draggingId !== null || createMode) {
|
||||
return undefined
|
||||
@@ -285,6 +254,12 @@ const LinkList: React.FC<LinkListProps> = ({
|
||||
setKeyboardActiveIndex(null)
|
||||
setActiveItemIndex(index)
|
||||
}}
|
||||
onFormClose={() => {
|
||||
setEditId(null)
|
||||
setActiveItemIndex(lastActiveIndexRef.current)
|
||||
setKeyboardActiveIndex(lastActiveIndexRef.current)
|
||||
console.log(keyboardActiveIndex)
|
||||
}}
|
||||
index={index}
|
||||
onItemSelected={link => setEditId(link.id)}
|
||||
data-keyboard-active={keyboardActiveIndex === index}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { useKey } from "react-use"
|
||||
import { LinkForm } from "./partials/form/link-form"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { parseAsBoolean, useQueryState } from "nuqs"
|
||||
@@ -12,9 +11,6 @@ const LinkManage: React.FC<LinkManageProps> = () => {
|
||||
const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean)
|
||||
|
||||
const handleFormClose = () => setCreateMode(false)
|
||||
const handleFormFail = () => {}
|
||||
|
||||
useKey("Escape", handleFormClose)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -25,7 +21,7 @@ const LinkManage: React.FC<LinkManageProps> = () => {
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} />
|
||||
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { FormField, FormItem, FormLabel } from "@/components/ui/form"
|
||||
import { LearningStateSelector } from "@/components/custom/learning-state-selector"
|
||||
import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector"
|
||||
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
|
||||
import { useOnClickOutside } from "@/hooks/use-on-click-outside"
|
||||
|
||||
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
|
||||
|
||||
@@ -78,26 +79,16 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
[exceptionsRefs, globalExceptionRefs]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const isClickInsideForm = formRef.current && formRef.current.contains(event.target as Node)
|
||||
|
||||
const isClickInsideExceptions = allExceptionRefs.some((ref, index) => {
|
||||
const isInside = ref.current && ref.current.contains(event.target as Node)
|
||||
return isInside
|
||||
})
|
||||
|
||||
if (!isClickInsideForm && !istopicSelectorOpen && !islearningStateSelectorOpen && !isClickInsideExceptions) {
|
||||
onClose?.()
|
||||
}
|
||||
useOnClickOutside(formRef, event => {
|
||||
if (
|
||||
!istopicSelectorOpen &&
|
||||
!islearningStateSelectorOpen &&
|
||||
!allExceptionRefs.some(ref => ref.current?.contains(event.target as Node))
|
||||
) {
|
||||
console.log("clicking outside")
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
}
|
||||
}, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose])
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedLink) {
|
||||
@@ -193,7 +184,15 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
const canSubmit = form.formState.isValid && !form.formState.isSubmitting
|
||||
|
||||
return (
|
||||
<div className="p-3 transition-all">
|
||||
<div
|
||||
tabIndex={-1}
|
||||
className="p-3 transition-all"
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Escape") {
|
||||
handleCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("bg-muted/30 relative rounded-md border", isFetching && "opacity-50")}>
|
||||
<Form {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1">
|
||||
@@ -213,7 +212,6 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
<LearningStateSelector
|
||||
value={field.value}
|
||||
onChange={value => {
|
||||
// toggle, if already selected set undefined
|
||||
form.setValue("learningState", field.value === value ? undefined : value)
|
||||
}}
|
||||
showSearch={false}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { useAtom } from "jotai"
|
||||
@@ -19,22 +19,18 @@ interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
personalLink: PersonalLink
|
||||
disabled?: boolean
|
||||
editId: string | null
|
||||
setEditId: (id: string | null) => void
|
||||
isActive: boolean
|
||||
setActiveItemIndex: (index: number | null) => void
|
||||
index: number
|
||||
onItemSelected?: (personalLink: PersonalLink) => void
|
||||
onFormClose?: () => void
|
||||
}
|
||||
|
||||
export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
(
|
||||
{ personalLink, disabled, editId, setEditId, isActive, setActiveItemIndex, index, onItemSelected, ...props },
|
||||
ref
|
||||
) => {
|
||||
({ personalLink, disabled, editId, isActive, index, onItemSelected, onFormClose, ...props }, ref) => {
|
||||
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
|
||||
|
||||
const style = useMemo(
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition
|
||||
@@ -42,15 +38,12 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
[transform, transition]
|
||||
)
|
||||
|
||||
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
|
||||
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
|
||||
|
||||
const selectedLearningState = useMemo(
|
||||
const selectedLearningState = React.useMemo(
|
||||
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
|
||||
[personalLink.learningState]
|
||||
)
|
||||
|
||||
const handleLearningStateSelect = useCallback(
|
||||
const handleLearningStateSelect = React.useCallback(
|
||||
(value: string) => {
|
||||
const learningState = value as LearningStateValue
|
||||
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
|
||||
@@ -59,10 +52,19 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
[personalLink, setOpenPopoverForId]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
onItemSelected?.(personalLink)
|
||||
}
|
||||
},
|
||||
[personalLink, onItemSelected]
|
||||
)
|
||||
|
||||
if (editId === personalLink.id) {
|
||||
return (
|
||||
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
|
||||
)
|
||||
return <LinkForm onClose={onFormClose} personalLink={personalLink} onSuccess={onFormClose} onFail={() => {}} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,12 +88,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
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}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Editor } from "@tiptap/core"
|
||||
import { generateUniqueSlug } from "@/lib/utils"
|
||||
import { FocusClasses } from "@tiptap/extension-focus"
|
||||
import { DetailPageHeader } from "./header"
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
import { TopicSelector } from "@/components/custom/topic-selector"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LaIcon } from "@/components/custom/la-icon"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
|
||||
export const useColumnStyles = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>(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 (
|
||||
<Command
|
||||
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", {
|
||||
@@ -109,7 +102,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
|
||||
onClick={handleClick}
|
||||
placeholder={filteredTopics[0]?.prettyName}
|
||||
className={cn("placeholder:text-muted-foreground flex-1 bg-transparent px-2 outline-none")}
|
||||
autoFocus // Add this line
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
|
||||
@@ -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 (
|
||||
<ContentHeader className="px-6 py-5 max-lg:px-4">
|
||||
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<SidebarToggleButton />
|
||||
<div className="flex min-h-0 items-center">
|
||||
<span className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</span>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center">
|
||||
<h1 className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-auto"></div>
|
||||
|
||||
<LearningStateSelector
|
||||
showSearch={false}
|
||||
value={p?.learningState || ""}
|
||||
onChange={handleAddToProfile}
|
||||
defaultLabel="Add to my profile"
|
||||
defaultLabel={isMobile ? "" : "Add to profile"}
|
||||
defaultIcon="Circle"
|
||||
/>
|
||||
</ContentHeader>
|
||||
)
|
||||
|
||||
@@ -151,12 +151,7 @@ export const LinkItem = React.memo(
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-52 rounded-lg p-0"
|
||||
side="bottom"
|
||||
align="start"
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
searchPlaceholder="Search state..."
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMedia } from "react-use"
|
||||
import { useMedia } from "@/hooks/use-media"
|
||||
|
||||
export const useColumnStyles = () => {
|
||||
const isTablet = useMedia("(max-width: 640px)")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -139,7 +139,6 @@ export const TopicItem = React.forwardRef<HTMLDivElement, TopicItemProps>(({ top
|
||||
side="bottom"
|
||||
align="end"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onCloseAutoFocus={e => e.preventDefault()}
|
||||
>
|
||||
<LearningStateSelectorContent
|
||||
showSearch={false}
|
||||
|
||||
@@ -11,11 +11,11 @@ export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemSc
|
||||
const { activeIndex } = options
|
||||
const elementRefs = useRef<ElementRefs<T>>([])
|
||||
|
||||
const scrollActiveElementIntoView = useCallback((index: number) => {
|
||||
const scrollActiveElementIntoView = (index: number) => {
|
||||
const activeElement = elementRefs.current[index]
|
||||
activeElement?.focus()
|
||||
// activeElement?.scrollIntoView({ block: "nearest" })
|
||||
}, [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeIndex !== null) {
|
||||
|
||||
33
web/hooks/use-event-listener.ts
Normal file
33
web/hooks/use-event-listener.ts
Normal file
@@ -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])
|
||||
}
|
||||
19
web/hooks/use-is-mounted.ts
Normal file
19
web/hooks/use-is-mounted.ts
Normal file
@@ -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, [])
|
||||
}
|
||||
79
web/hooks/use-key-down.ts
Normal file
79
web/hooks/use-key-down.ts
Normal file
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
23
web/hooks/use-media.ts
Normal file
23
web/hooks/use-media.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
export function useMedia(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(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
|
||||
}
|
||||
28
web/hooks/use-on-click-outside.ts
Normal file
28
web/hooks/use-on-click-outside.ts
Normal file
@@ -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<HTMLElement | null>,
|
||||
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)
|
||||
}
|
||||
34
web/hooks/use-throttle.ts
Normal file
34
web/hooks/use-throttle.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useRef, useCallback } from "react"
|
||||
|
||||
export function useThrottle<T extends (...args: any[]) => void>(
|
||||
callback: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
const lastRan = useRef(Date.now())
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
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]
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -34,22 +34,16 @@ export function shuffleArray<T>(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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user