mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-21 07:51:38 +02: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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user