fix: conflict

This commit is contained in:
Aslam H
2024-09-23 23:28:04 +07:00
46 changed files with 972 additions and 755 deletions
+2 -3
View File
@@ -17,11 +17,10 @@
"@clerk/themes": "^2.1.30", "@clerk/themes": "^2.1.30",
"@tauri-apps/cli": "^2.0.0-rc.16", "@tauri-apps/cli": "^2.0.0-rc.16",
"@tauri-apps/plugin-fs": "^2.0.0-rc.2", "@tauri-apps/plugin-fs": "^2.0.0-rc.2",
"jazz-nodejs": "0.7.35-guest-auth.5", "jazz-nodejs": "0.8.0"
"react-icons": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^1.1.28" "bun-types": "^1.1.29"
}, },
"prettier": { "prettier": {
"plugins": [ "plugins": [
+2 -3
View File
@@ -3,7 +3,7 @@ import { Sidebar } from "@/components/custom/sidebar/sidebar"
import { CommandPalette } from "@/components/custom/command-palette/command-palette" import { CommandPalette } from "@/components/custom/command-palette/command-palette"
import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
import { Shortcut } from "@/components/custom/Shortcut/shortcut" 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 = { export const viewport: Viewport = {
width: "device-width, shrink-to-fit=no", 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"> <div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar /> <Sidebar />
<LearnAnythingOnboarding /> <LearnAnythingOnboarding />
<GlobalKeydownHandler /> <GlobalKeyboardHandler />
<CommandPalette /> <CommandPalette />
<Shortcut /> <Shortcut />
+11
View File
@@ -0,0 +1,11 @@
:root {
--link-background-muted: hsl(0, 0%, 97.3%);
--link-border-after: hsl(0, 0%, 91%);
--link-shadow: hsl(240, 5.6%, 82.5%);
}
.dark {
--link-background-muted: hsl(220, 6.7%, 8.8%);
--link-border-after: hsl(230, 10%, 11.8%);
--link-shadow: hsl(234.9, 27.1%, 25.3%);
}
+1
View File
@@ -73,3 +73,4 @@
} }
@import "./command-palette.css"; @import "./command-palette.css";
@import "./custom.css";
@@ -38,6 +38,14 @@ const SHORTCUTS: ShortcutSection[] = [
{ label: "Go to page", keys: ["G"], then: ["P"] }, { label: "Go to page", keys: ["G"], then: ["P"] },
{ label: "Go to topic", keys: ["G"], then: ["T"] } { 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 { GraphNode } from "@/components/routes/public/PublicHomeRoute"
import { useCommandActions } from "./hooks/use-command-actions" import { useCommandActions } from "./hooks/use-command-actions"
import { atom, useAtom } from "jotai" 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) 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 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(() => { const bounce = React.useCallback(() => {
if (dialogRef.current) { if (dialogRef.current) {
dialogRef.current.style.transform = "scale(0.99) translateX(-50%)" dialogRef.current.style.transform = "scale(0.99) translateX(-50%)"
+5 -5
View File
@@ -1,12 +1,12 @@
"use client" "use client"
import React from "react" import * as React from "react"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { PanelLeftIcon } from "lucide-react"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar" import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar"
import { useMedia } from "react-use" import { useMedia } from "@/hooks/use-media"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { LaIcon } from "./la-icon"
type ContentHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title"> type ContentHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "title">
@@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef<HTMLDivElement, ContentHeaderProps
return ( return (
<header <header
className={cn( 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 className
)} )}
ref={ref} ref={ref}
@@ -52,7 +52,7 @@ export const SidebarToggleButton: React.FC = () => {
className="text-primary/60" className="text-primary/60"
onClick={handleClick} onClick={handleClick}
> >
<PanelLeftIcon size={16} /> <LaIcon name="PanelLeft" />
</Button> </Button>
</div> </div>
) )
@@ -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 { linkLearningStateSelectorAtom } from "@/store/link"
import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command" import { Command, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import type { icons } from "lucide-react"
interface LearningStateSelectorProps { interface LearningStateSelectorProps {
showSearch?: boolean showSearch?: boolean
@@ -16,6 +17,7 @@ interface LearningStateSelectorProps {
value?: string value?: string
onChange: (value: LearningStateValue) => void onChange: (value: LearningStateValue) => void
className?: string className?: string
defaultIcon?: keyof typeof icons
} }
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
@@ -24,7 +26,8 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
searchPlaceholder = "Search state...", searchPlaceholder = "Search state...",
value, value,
onChange, onChange,
className className,
defaultIcon
}) => { }) => {
const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom) const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom(linkLearningStateSelectorAtom)
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value]) const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === value), [value])
@@ -44,21 +47,24 @@ export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
variant="secondary" variant="secondary"
className={cn("gap-x-2 text-sm", className)} className={cn("gap-x-2 text-sm", className)}
> >
{selectedLearningState?.icon && ( {selectedLearningState?.icon ||
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} /> (defaultIcon && (
)} <LaIcon
<span className={cn("truncate", selectedLearningState?.className || "")}> name={selectedLearningState?.icon || defaultIcon}
{selectedLearningState?.label || defaultLabel} className={cn(selectedLearningState?.className)}
</span> />
))}
{selectedLearningState?.label ||
(defaultLabel && (
<span className={cn("truncate", selectedLearningState?.className || "")}>
{selectedLearningState?.label || defaultLabel}
</span>
))}
<LaIcon name="ChevronDown" /> <LaIcon name="ChevronDown" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="end">
className="w-52 rounded-lg p-0"
side="bottom"
align="end"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent <LearningStateSelectorContent
showSearch={showSearch} showSearch={showSearch}
searchPlaceholder={searchPlaceholder} 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" isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"
)} )}
> >
<Link <Link href="/pages" className="flex flex-1 items-center justify-start rounded-md px-2 py-1">
href="/pages"
className="flex flex-1 items-center justify-start rounded-md px-2 py-1 focus-visible:outline-none focus-visible:ring-0"
>
<p className="text-xs"> <p className="text-xs">
Pages Pages
{pageCount > 0 && <span className="text-muted-foreground ml-1">{pageCount}</span>} {pageCount > 0 && <span className="text-muted-foreground ml-1">{pageCount}</span>}
@@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react" import * as React from "react"
import { SignInButton, useAuth, useUser } from "@clerk/nextjs" import { SignInButton, useAuth, useUser } from "@clerk/nextjs"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import Link from "next/link" import Link from "next/link"
@@ -27,13 +27,13 @@ import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
export const ProfileSection: React.FC = () => { export const ProfileSection: React.FC = () => {
const { user, isSignedIn } = useUser() const { user, isSignedIn } = useUser()
const { signOut } = useAuth() const { signOut } = useAuth()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = React.useState(false)
const pathname = usePathname() const pathname = usePathname()
const [, setShowShortcut] = useAtom(showShortcutAtom) const [, setShowShortcut] = useAtom(showShortcutAtom)
const { disableKeydown } = useKeyboardManager("profileSection") const { disableKeydown } = useKeyboardManager("profileSection")
useEffect(() => { React.useEffect(() => {
disableKeydown(menuOpen) disableKeydown(menuOpen)
}, [menuOpen, disableKeydown]) }, [menuOpen, disableKeydown])
+5 -5
View File
@@ -3,9 +3,8 @@
import * as React from "react" import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useMedia } from "react-use" import { useMedia } from "@/hooks/use-media"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { SearchIcon } from "lucide-react"
import { Logo } from "@/components/custom/logo" import { Logo } from "@/components/custom/logo"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -16,6 +15,7 @@ import { TopicSection } from "./partial/topic-section"
import { ProfileSection } from "./partial/profile-section" import { ProfileSection } from "./partial/profile-section"
import { TaskSection } from "./partial/task-section" import { TaskSection } from "./partial/task-section"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider" import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { LaIcon } from "../la-icon"
interface SidebarContextType { interface SidebarContextType {
isCollapsed: boolean isCollapsed: boolean
@@ -99,7 +99,7 @@ const LogoAndSearch: React.FC = React.memo(() => {
type="button" type="button"
className="text-primary/60 flex w-20 items-center justify-start py-4 pl-2" 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> </Button>
</Link> </Link>
)} )}
@@ -120,13 +120,13 @@ const SidebarContent: React.FC = React.memo(() => {
<div> <div>
<LogoAndSearch /> <LogoAndSearch />
</div> </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" /> <div className="h-2 shrink-0" />
{me._type === "Account" && <LinkSection pathname={pathname} />} {me._type === "Account" && <LinkSection pathname={pathname} />}
{me._type === "Account" && <PageSection pathname={pathname} />}
{me._type === "Account" && <TopicSection pathname={pathname} />} {me._type === "Account" && <TopicSection pathname={pathname} />}
{/* {me._type === "Account" && <TaskSection pathname={pathname} />} */} {/* {me._type === "Account" && <TaskSection pathname={pathname} />} */}
<TaskSection pathname={pathname} /> <TaskSection pathname={pathname} />
{me._type === "Account" && <PageSection pathname={pathname} />}
</div> </div>
<ProfileSection /> <ProfileSection />
+1 -6
View File
@@ -79,12 +79,7 @@ export const TopicSelector = forwardRef<HTMLButtonElement, TopicSelectorProps>(
<LaIcon name="ChevronDown" /> <LaIcon name="ChevronDown" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-52 rounded-lg p-0" side={side} align={align}>
className="w-52 rounded-lg p-0"
side={side}
align={align}
onCloseAutoFocus={e => e.preventDefault()}
>
{group?.root.topics && ( {group?.root.topics && (
<TopicSelectorContent <TopicSelectorContent
showSearch={showSearch} showSearch={showSearch}
+5 -35
View File
@@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react"
import { EditorContent, useEditor } from "@tiptap/react" import { EditorContent, useEditor } from "@tiptap/react"
import { Editor, Content } from "@tiptap/core" import { Editor, Content } from "@tiptap/core"
import { useThrottleFn } from "react-use"
import { BubbleMenu } from "./components/bubble-menu" import { BubbleMenu } from "./components/bubble-menu"
import { createExtensions } from "./extensions" import { createExtensions } from "./extensions"
import "./styles/index.css" import "./styles/index.css"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { getOutput } 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"> { export interface LAEditorProps extends Omit<React.HTMLProps<HTMLDivElement>, "value"> {
output?: "html" | "json" | "text" output?: "html" | "json" | "text"
@@ -25,10 +25,6 @@ export interface LAEditorRef {
editor: Editor | null editor: Editor | null
} }
interface CustomEditor extends Editor {
previousBlockCount?: number
}
export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>( export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
( (
{ {
@@ -46,32 +42,13 @@ export const LAEditor = React.forwardRef<LAEditorRef, LAEditorProps>(
}, },
ref ref
) => { ) => {
const [content, setContent] = React.useState<Content | undefined>(value) const throttledSetValue = useThrottle((value: Content) => onUpdate?.(value), throttleDelay)
const throttledContent = useThrottleFn(defaultContent => defaultContent, throttleDelay, [content])
const [lastThrottledContent, setLastThrottledContent] = React.useState(throttledContent)
const handleUpdate = React.useCallback( const handleUpdate = React.useCallback(
(editor: Editor) => { (editor: Editor) => {
const newContent = getOutput(editor, output) throttledSetValue(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
}
}, },
[output, onNewBlock] [output, throttledSetValue]
) )
const editor = useEditor({ 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( React.useImperativeHandle(
ref, ref,
() => ({ () => ({
+3 -54
View File
@@ -1,71 +1,20 @@
"use client" "use client"
import React, { useEffect, useState, useCallback, useRef } from "react" import * as React from "react"
import { LinkHeader } from "@/components/routes/link/header" import { LinkHeader } from "@/components/routes/link/header"
import { LinkList } from "@/components/routes/link/list" import { LinkList } from "@/components/routes/link/list"
import { LinkManage } from "@/components/routes/link/manage" import { LinkManage } from "@/components/routes/link/manage"
import { parseAsBoolean, useQueryState } from "nuqs" import { atom } from "jotai"
import { atom, useAtom } from "jotai"
import { LinkBottomBar } from "./bottom-bar" import { LinkBottomBar } from "./bottom-bar"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { useKey } from "react-use"
export const isDeleteConfirmShownAtom = atom(false) export const isDeleteConfirmShownAtom = atom(false)
export function LinkRoute(): React.ReactElement { export function LinkRoute(): React.ReactElement {
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const [isDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
const [disableEnterKey, setDisableEnterKey] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const handleCommandPaletteClose = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
setDisableEnterKey(true)
timeoutRef.current = setTimeout(() => {
setDisableEnterKey(false)
timeoutRef.current = null
}, 100)
}, [])
useEffect(() => {
if (isDeleteConfirmShown || isCommandPaletteOpen || isInCreateMode) {
setDisableEnterKey(true)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
} else if (!isCommandPaletteOpen) {
handleCommandPaletteClose()
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
useKey("Escape", () => {
setDisableEnterKey(false)
setNuqsEditId(null)
})
return ( return (
<> <>
<LinkHeader /> <LinkHeader />
<LinkManage /> <LinkManage />
<LinkList <LinkList />
key={nuqsEditId}
activeItemIndex={activeItemIndex}
setActiveItemIndex={setActiveItemIndex}
disableEnterKey={disableEnterKey}
/>
<LinkBottomBar /> <LinkBottomBar />
</> </>
) )
+29 -41
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import React, { useCallback, useEffect, useRef } from "react" import * as React from "react"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import type { icons } from "lucide-react" import type { icons } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -15,7 +15,6 @@ import { PersonalLink } from "@/lib/schema"
import { ID } from "jazz-tools" import { ID } from "jazz-tools"
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form" import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
import { useLinkActions } from "./hooks/use-link-actions" import { useLinkActions } from "./hooks/use-link-actions"
import { useKeydownListener } from "@/hooks/use-keydown-listener"
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> { interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
icon: keyof typeof icons icon: keyof typeof icons
@@ -55,28 +54,28 @@ export const LinkBottomBar: React.FC = () => {
const { me } = useAccount({ root: { personalLinks: [] } }) const { me } = useAccount({ root: { personalLinks: [] } })
const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>) const personalLink = useCoState(PersonalLink, editId as ID<PersonalLink>)
const cancelBtnRef = useRef<HTMLButtonElement>(null) const cancelBtnRef = React.useRef<HTMLButtonElement>(null)
const confirmBtnRef = useRef<HTMLButtonElement>(null) const confirmBtnRef = React.useRef<HTMLButtonElement>(null)
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = React.useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null) const contentRef = React.useRef<HTMLDivElement>(null)
const deleteBtnRef = useRef<HTMLButtonElement>(null) const deleteBtnRef = React.useRef<HTMLButtonElement>(null)
const editMoreBtnRef = useRef<HTMLButtonElement>(null) const editMoreBtnRef = React.useRef<HTMLButtonElement>(null)
const plusBtnRef = useRef<HTMLButtonElement>(null) const plusBtnRef = React.useRef<HTMLButtonElement>(null)
const plusMoreBtnRef = useRef<HTMLButtonElement>(null) const plusMoreBtnRef = React.useRef<HTMLButtonElement>(null)
const { deleteLink } = useLinkActions() const { deleteLink } = useLinkActions()
const confirm = useConfirm() const confirm = useConfirm()
const handleCreateMode = useCallback(() => { const handleCreateMode = React.useCallback(() => {
setEditId(null) setEditId(null)
setTimeout(() => { requestAnimationFrame(() => {
setCreateMode(prev => !prev) setCreateMode(prev => !prev)
}, 100) })
}, [setEditId, setCreateMode]) }, [setEditId, setCreateMode])
useEffect(() => { const exceptionRefs = React.useMemo(
setGlobalLinkFormExceptionRefsAtom([ () => [
overlayRef, overlayRef,
contentRef, contentRef,
deleteBtnRef, deleteBtnRef,
@@ -85,8 +84,13 @@ export const LinkBottomBar: React.FC = () => {
confirmBtnRef, confirmBtnRef,
plusBtnRef, plusBtnRef,
plusMoreBtnRef plusMoreBtnRef
]) ],
}, [setGlobalLinkFormExceptionRefsAtom]) []
)
React.useEffect(() => {
setGlobalLinkFormExceptionRefsAtom(exceptionRefs)
}, [setGlobalLinkFormExceptionRefsAtom, exceptionRefs])
const handleDelete = async (e: React.MouseEvent) => { const handleDelete = async (e: React.MouseEvent) => {
if (!personalLink || !me) return if (!personalLink || !me) return
@@ -119,46 +123,29 @@ export const LinkBottomBar: React.FC = () => {
} }
} }
const handleKeydown = useCallback(
(event: KeyboardEvent) => {
const isCreateShortcut = event.key === "c"
if (isCreateShortcut) {
event.preventDefault()
handleCreateMode()
}
},
[handleCreateMode]
)
useKeydownListener(handleKeydown)
const shortcutText = getShortcutKeys(["c"]) const shortcutText = getShortcutKeys(["c"])
return ( return (
<motion.div <div className="bg-background min-h-11 border-t">
className="bg-background absolute bottom-0 left-0 right-0 h-11 border-t"
animate={{ y: 0 }}
initial={{ y: "100%" }}
>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{editId && ( {editId && (
<motion.div <motion.div
key="expanded" key="expanded"
className="flex h-full items-center justify-center gap-1 px-2" className="flex h-full items-center justify-center gap-1 border-t px-2"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }} exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
> >
<ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} /> <ToolbarButton icon={"ArrowLeft"} onClick={() => setEditId(null)} aria-label="Go back" />
<ToolbarButton <ToolbarButton
icon={"Trash"} icon={"Trash"}
onClick={handleDelete} onClick={handleDelete}
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
ref={deleteBtnRef} ref={deleteBtnRef}
aria-label="Delete link"
/> />
<ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} /> <ToolbarButton icon={"Ellipsis"} ref={editMoreBtnRef} aria-label="More options" />
</motion.div> </motion.div>
)} )}
@@ -171,19 +158,20 @@ export const LinkBottomBar: React.FC = () => {
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
> >
{createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} />} {createMode && <ToolbarButton icon={"ArrowLeft"} onClick={handleCreateMode} aria-label="Go back" />}
{!createMode && ( {!createMode && (
<ToolbarButton <ToolbarButton
icon={"Plus"} icon={"Plus"}
onClick={handleCreateMode} onClick={handleCreateMode}
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`} tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
ref={plusBtnRef} ref={plusBtnRef}
aria-label="New link"
/> />
)} )}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</motion.div> </div>
) )
} }
+5 -5
View File
@@ -1,10 +1,9 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { ListFilterIcon } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header" 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 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 { useQueryState, parseAsStringLiteral } from "nuqs"
import { FancySwitch } from "@omit/react-fancy-switch" import { FancySwitch } from "@omit/react-fancy-switch"
import { cn } from "@/lib/utils" 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 = [{ label: "All", value: "all", icon: "List", className: "text-foreground" }, ...LEARNING_STATES]
const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value) const ALL_STATES_STRING = ALL_STATES.map(ls => ls.value)
@@ -42,7 +42,7 @@ export const LinkHeader = React.memo(() => {
</ContentHeader> </ContentHeader>
{isTablet && ( {isTablet && (
<div className="flex min-h-10 flex-row items-start justify-between border-b px-6 py-2 max-lg:pl-4"> <div className="flex min-h-10 flex-row items-start justify-between border-b px-6 pb-4 pt-2 max-lg:pl-4">
<LearningTab /> <LearningTab />
</div> </div>
)} )}
@@ -115,8 +115,8 @@ const FilterAndSort = React.memo(() => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Popover open={sortOpen} onOpenChange={setSortOpen}> <Popover open={sortOpen} onOpenChange={setSortOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button size="sm" type="button" variant="secondary" className="gap-x-2 text-sm"> <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> <span className="hidden md:block">Filter: {getFilterText()}</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -9,18 +9,20 @@ export const useLinkActions = () => {
try { try {
const index = me.root.personalLinks.findIndex(item => item?.id === link.id) const index = me.root.personalLinks.findIndex(item => item?.id === link.id)
if (index === -1) { if (index === -1) {
console.error("Delete operation fail", { index, link }) throw new Error(`Link with id ${link.id} not found`)
return
} }
me.root.personalLinks.splice(index, 1)
toast.success("Link deleted.", { toast.success("Link deleted.", {
position: "bottom-right", position: "bottom-right",
description: `${link.title} has been deleted.` description: `${link.title} has been deleted.`
}) })
me.root.personalLinks.splice(index, 1)
} catch (error) { } catch (error) {
toast.error("Failed to delete link") console.error("Failed to delete link:", error)
toast.error("Failed to delete link", {
description: error instanceof Error ? error.message : "An unknown error occurred"
})
} }
}, []) }, [])
+149 -147
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from "react" import * as React from "react"
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -8,50 +8,55 @@ import {
useSensors, useSensors,
DragEndEvent, DragEndEvent,
DragStartEvent, DragStartEvent,
UniqueIdentifier UniqueIdentifier,
MeasuringStrategy,
TouchSensor
} from "@dnd-kit/core" } from "@dnd-kit/core"
import { Primitive } from "@radix-ui/react-primitive"
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable" import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from "@dnd-kit/sortable"
import type { MeasuringConfiguration } from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import { useAccount } from "@/lib/providers/jazz-provider" import { useAccount } from "@/lib/providers/jazz-provider"
import { PersonalLinkLists } from "@/lib/schema/personal-link" import { PersonalLinkLists } from "@/lib/schema/personal-link"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { linkSortAtom } from "@/store/link" import { linkSortAtom } from "@/store/link"
import { useKey } from "react-use"
import { LinkItem } from "./partials/link-item" import { LinkItem } from "./partials/link-item"
import { useQueryState } from "nuqs" import { parseAsBoolean, useQueryState } from "nuqs"
import { learningStateAtom } from "./header" import { learningStateAtom } from "./header"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { useConfirm } from "@omit/react-confirm-dialog" import { useConfirm } from "@omit/react-confirm-dialog"
import { useLinkActions } from "./hooks/use-link-actions" import { useLinkActions } from "./hooks/use-link-actions"
import { isDeleteConfirmShownAtom } from "./LinkRoute" import { isDeleteConfirmShownAtom } from "./LinkRoute"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { useKeyboardManager } from "@/hooks/use-keyboard-manager" import { useTouchSensor } from "@/hooks/use-touch-sensor"
import { useKeydownListener } from "@/hooks/use-keydown-listener" import { useKeyDown } from "@/hooks/use-key-down"
import { isModKey } from "@/lib/utils"
interface LinkListProps { interface LinkListProps {}
activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>> const measuring: MeasuringConfiguration = {
disableEnterKey: boolean droppable: {
strategy: MeasuringStrategy.Always
}
} }
const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => { const LinkList: React.FC<LinkListProps> = () => {
const [isCommandPalettePpen] = useAtom(commandPaletteOpenAtom) const isTouchDevice = useTouchSensor()
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 [, setIsDeleteConfirmShown] = useAtom(isDeleteConfirmShownAtom)
const [editId, setEditId] = useQueryState("editId") const [editId, setEditId] = useQueryState("editId")
const [createMode] = useQueryState("create", parseAsBoolean)
const [activeLearningState] = useAtom(learningStateAtom) const [activeLearningState] = useAtom(learningStateAtom)
const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null) const [draggingId, setDraggingId] = React.useState<UniqueIdentifier | null>(null)
const [sort] = useAtom(linkSortAtom)
const { deleteLink } = useLinkActions() const { deleteLink } = useLinkActions()
const confirm = useConfirm() const confirm = useConfirm()
const { me } = useAccount({ root: { personalLinks: [] } })
const { me } = useAccount({ const personalLinks = React.useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
root: { personalLinks: [] }
})
const personalLinks = useMemo(() => me?.root?.personalLinks || [], [me?.root?.personalLinks])
const [sort] = useAtom(linkSortAtom) const filteredLinks = React.useMemo(
const filteredLinks = useMemo(
() => () =>
personalLinks.filter(link => { personalLinks.filter(link => {
if (activeLearningState === "all") return true if (activeLearningState === "all") return true
@@ -61,7 +66,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
[personalLinks, activeLearningState] [personalLinks, activeLearningState]
) )
const sortedLinks = useMemo( const sortedLinks = React.useMemo(
() => () =>
sort === "title" sort === "title"
? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || "")) ? [...filteredLinks].sort((a, b) => (a?.title || "").localeCompare(b?.title || ""))
@@ -69,10 +74,22 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
[filteredLinks, sort] [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( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(isTouchDevice ? TouchSensor : PointerSensor, {
activationConstraint: { activationConstraint: {
distance: 8 ...(isTouchDevice ? { delay: 100, tolerance: 5 } : {}),
distance: 5
} }
}), }),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
@@ -80,52 +97,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
}) })
) )
useKey( const updateSequences = React.useCallback((links: PersonalLinkLists) => {
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
async () => {
if (activeItemIndex !== null) {
setIsDeleteConfirmShown(true)
const activeLink = sortedLinks[activeItemIndex]
if (activeLink) {
const result = await confirm({
title: `Delete "${activeLink.title}"?`,
description: "This action cannot be undone.",
alertDialogTitle: {
className: "text-base"
},
cancelButton: {
variant: "outline"
},
confirmButton: {
variant: "destructive"
}
})
if (result) {
if (!me) return
deleteLink(me, activeLink)
setIsDeleteConfirmShown(false)
} else {
setIsDeleteConfirmShown(false)
}
}
}
},
{ event: "keydown" }
)
// on mounted, if editId is set, set activeItemIndex to the index of the item with the editId
useEffect(() => {
if (editId) {
const index = sortedLinks.findIndex(link => link?.id === editId)
if (index !== -1) {
setActiveItemIndex(index)
}
}
}, [editId, sortedLinks, setActiveItemIndex])
const updateSequences = useCallback((links: PersonalLinkLists) => {
links.forEach((link, index) => { links.forEach((link, index) => {
if (link) { if (link) {
link.sequence = index link.sequence = index
@@ -133,62 +105,73 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
}) })
}, []) }, [])
const { isKeyboardDisabled } = useKeyboardManager("XComponent") const handleDeleteLink = React.useCallback(async () => {
if (activeItemIndex === null) return
setIsDeleteConfirmShown(true)
const activeLink = sortedLinks[activeItemIndex]
if (!activeLink || !me) return
useKeydownListener((e: KeyboardEvent) => { const result = await confirm({
if ( title: `Delete "${activeLink.title}"?`,
isKeyboardDisabled || description: "This action cannot be undone.",
isCommandPalettePpen || alertDialogTitle: { className: "text-base" },
!me?.root?.personalLinks || cancelButton: { variant: "outline" },
sortedLinks.length === 0 || confirmButton: { variant: "destructive" }
editId !== null })
)
return
if (e.key === "ArrowUp" || e.key === "ArrowDown") { if (result) {
e.preventDefault() deleteLink(me, activeLink)
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)
}
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
e.preventDefault()
const activeLink = sortedLinks[activeItemIndex]
if (activeLink) {
setEditId(activeLink.id)
}
} }
}) setIsDeleteConfirmShown(false)
}, [activeItemIndex, sortedLinks, me, confirm, deleteLink, setIsDeleteConfirmShown])
const handleDragStart = useCallback( useKeyDown(e => isModKey(e) && e.key === "Backspace", handleDeleteLink)
const next = () => Math.min((activeItemIndex ?? 0) + 1, sortedLinks.length - 1)
const prev = () => Math.max((activeItemIndex ?? 0) - 1, 0)
const handleKeyDown = (ev: KeyboardEvent) => {
switch (ev.key) {
case "ArrowDown":
ev.preventDefault()
ev.stopPropagation()
setActiveItemIndex(next())
setKeyboardActiveIndex(next())
break
case "ArrowUp":
ev.preventDefault()
ev.stopPropagation()
setActiveItemIndex(prev())
setKeyboardActiveIndex(prev())
}
}
useKeyDown(() => true, handleKeyDown)
const handleDragStart = React.useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
if (sort !== "manual") return if (sort !== "manual") return
if (!me) return
const { active } = event const { active } = event
const activeIndex = me?.root.personalLinks.findIndex(item => item?.id === active.id)
if (activeIndex === -1) {
console.error("Drag operation fail", { activeIndex, activeId: active.id })
return
}
setActiveItemIndex(activeIndex)
setDraggingId(active.id) setDraggingId(active.id)
}, },
[sort] [sort, me, setActiveItemIndex]
) )
const handleDragCancel = React.useCallback(() => {
setDraggingId(null)
}, [])
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event const { active, over } = event
@@ -226,51 +209,70 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
}) })
updateSequences(me.root.personalLinks) updateSequences(me.root.personalLinks)
setActiveItemIndex(newIndex)
} catch (error) { } catch (error) {
console.error("Error during link reordering:", error) console.error("Error during link reordering:", error)
} }
} }
setActiveItemIndex(null)
setDraggingId(null) setDraggingId(null)
} }
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex }) const { setElementRef } = useActiveItemScroll<HTMLDivElement>({
activeIndex: keyboardActiveIndex
})
return ( return (
<Primitive.div <DndContext
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]" sensors={sensors}
tabIndex={0} collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
measuring={measuring}
modifiers={[restrictToVerticalAxis]}
> >
<DndContext <div className="relative flex h-full grow items-stretch overflow-hidden" tabIndex={-1}>
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}> <SortableContext items={sortedLinks.map(item => item?.id || "") || []} strategy={verticalListSortingStrategy}>
<ul role="list" className="divide-primary/5 divide-y"> <div className="relative flex h-full grow flex-col items-stretch overflow-hidden">
{sortedLinks.map( <div className="flex h-full w-[calc(100%+0px)] flex-col overflow-hidden pr-0">
(linkItem, index) => <div className="relative overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
linkItem && ( {sortedLinks.map(
<LinkItem (linkItem, index) =>
key={linkItem.id} linkItem && (
isEditing={editId === linkItem.id} <LinkItem
setEditId={setEditId} key={linkItem.id}
personalLink={linkItem} isActive={activeItemIndex === index}
disabled={sort !== "manual" || editId !== null} personalLink={linkItem}
isDragging={draggingId === linkItem.id} editId={editId}
isActive={activeItemIndex === index} disabled={sort !== "manual" || editId !== null}
setActiveItemIndex={setActiveItemIndex} onPointerMove={() => {
index={index} if (editId !== null || draggingId !== null || createMode) {
ref={el => setElementRef(el, index)} return undefined
/> }
)
)} setKeyboardActiveIndex(null)
</ul> 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}
ref={el => setElementRef(el, index)}
/>
)
)}
</div>
</div>
</div>
</SortableContext> </SortableContext>
</DndContext> </div>
</Primitive.div> </DndContext>
) )
} }
+1 -5
View File
@@ -1,7 +1,6 @@
"use client" "use client"
import React from "react" import React from "react"
import { useKey } from "react-use"
import { LinkForm } from "./partials/form/link-form" import { LinkForm } from "./partials/form/link-form"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import { parseAsBoolean, useQueryState } from "nuqs" import { parseAsBoolean, useQueryState } from "nuqs"
@@ -12,9 +11,6 @@ const LinkManage: React.FC<LinkManageProps> = () => {
const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean) const [createMode, setCreateMode] = useQueryState("create", parseAsBoolean)
const handleFormClose = () => setCreateMode(false) const handleFormClose = () => setCreateMode(false)
const handleFormFail = () => {}
useKey("Escape", handleFormClose)
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -25,7 +21,7 @@ const LinkManage: React.FC<LinkManageProps> = () => {
exit={{ height: 0, opacity: 0 }} exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.1 }} transition={{ duration: 0.1 }}
> >
<LinkForm onClose={handleFormClose} onSuccess={handleFormClose} onFail={handleFormFail} /> <LinkForm onClose={handleFormClose} onSuccess={handleFormClose} />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -19,6 +19,7 @@ import { FormField, FormItem, FormLabel } from "@/components/ui/form"
import { LearningStateSelector } from "@/components/custom/learning-state-selector" import { LearningStateSelector } from "@/components/custom/learning-state-selector"
import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector" import { TopicSelector, topicSelectorAtom } from "@/components/custom/topic-selector"
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
import { useOnClickOutside } from "@/hooks/use-on-click-outside"
export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([]) export const globalLinkFormExceptionRefsAtom = atom<React.RefObject<HTMLElement>[]>([])
@@ -78,26 +79,16 @@ export const LinkForm: React.FC<LinkFormProps> = ({
[exceptionsRefs, globalExceptionRefs] [exceptionsRefs, globalExceptionRefs]
) )
React.useEffect(() => { useOnClickOutside(formRef, event => {
const handleClickOutside = (event: MouseEvent) => { if (
const isClickInsideForm = formRef.current && formRef.current.contains(event.target as Node) !istopicSelectorOpen &&
!islearningStateSelectorOpen &&
const isClickInsideExceptions = allExceptionRefs.some((ref, index) => { !allExceptionRefs.some(ref => ref.current?.contains(event.target as Node))
const isInside = ref.current && ref.current.contains(event.target as Node) ) {
return isInside console.log("clicking outside")
}) onClose?.()
if (!isClickInsideForm && !istopicSelectorOpen && !islearningStateSelectorOpen && !isClickInsideExceptions) {
onClose?.()
}
} }
})
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [islearningStateSelectorOpen, istopicSelectorOpen, allExceptionRefs, onClose])
React.useEffect(() => { React.useEffect(() => {
if (selectedLink) { if (selectedLink) {
@@ -193,7 +184,15 @@ export const LinkForm: React.FC<LinkFormProps> = ({
const canSubmit = form.formState.isValid && !form.formState.isSubmitting const canSubmit = form.formState.isValid && !form.formState.isSubmitting
return ( 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")}> <div className={cn("bg-muted/30 relative rounded-md border", isFetching && "opacity-50")}>
<Form {...form}> <Form {...form}>
<form ref={formRef} onSubmit={form.handleSubmit(onSubmit)} className="relative min-w-0 flex-1"> <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 <LearningStateSelector
value={field.value} value={field.value}
onChange={value => { onChange={value => {
// toggle, if already selected set undefined
form.setValue("learningState", field.value === value ? undefined : value) form.setValue("learningState", field.value === value ? undefined : value)
}} }}
showSearch={false} showSearch={false}
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from "react" import * as React from "react"
import Image from "next/image" import Image from "next/image"
import Link from "next/link" import Link from "next/link"
import { useAtom } from "jotai" import { useAtom } from "jotai"
@@ -15,41 +15,35 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { linkOpenPopoverForIdAtom } from "@/store/link" import { linkOpenPopoverForIdAtom } from "@/store/link"
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> { interface LinkItemProps extends React.HTMLAttributes<HTMLDivElement> {
personalLink: PersonalLink personalLink: PersonalLink
disabled?: boolean disabled?: boolean
isEditing: boolean editId: string | null
setEditId: (id: string | null) => void
isDragging: boolean
isActive: boolean isActive: boolean
setActiveItemIndex: (index: number | null) => void
index: number index: number
onItemSelected?: (personalLink: PersonalLink) => void
onFormClose?: () => void
} }
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>( export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => { ({ personalLink, disabled, editId, isActive, index, onItemSelected, onFormClose, ...props }, ref) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom) const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled }) const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const style = useMemo( const style = React.useMemo(
() => ({ () => ({
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition
pointerEvents: isDragging ? "none" : "auto"
}), }),
[transform, transition, isDragging] [transform, transition]
) )
const handleSuccess = useCallback(() => setEditId(null), [setEditId]) const selectedLearningState = React.useMemo(
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState), () => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
[personalLink.learningState] [personalLink.learningState]
) )
const handleLearningStateSelect = useCallback( const handleLearningStateSelect = React.useCallback(
(value: string) => { (value: string) => {
const learningState = value as LearningStateValue const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
@@ -58,14 +52,23 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
[personalLink, setOpenPopoverForId] [personalLink, setOpenPopoverForId]
) )
if (isEditing) { const handleKeyDown = React.useCallback(
return ( (ev: React.KeyboardEvent<HTMLDivElement>) => {
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} /> if (ev.key === "Enter") {
) ev.preventDefault()
ev.stopPropagation()
onItemSelected?.(personalLink)
}
},
[personalLink, onItemSelected]
)
if (editId === personalLink.id) {
return <LinkForm onClose={onFormClose} personalLink={personalLink} onSuccess={onFormClose} onFail={() => {}} />
} }
return ( return (
<li <div
ref={node => { ref={node => {
setNodeRef(node) setNodeRef(node)
if (typeof ref === "function") { if (typeof ref === "function") {
@@ -75,61 +78,68 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
} }
}} }}
style={style as React.CSSProperties} style={style as React.CSSProperties}
{...props}
{...attributes} {...attributes}
{...listeners} {...listeners}
tabIndex={0} tabIndex={0}
onFocus={() => setActiveItemIndex(index)} onDoubleClick={() => onItemSelected?.(personalLink)}
onBlur={() => setActiveItemIndex(null)} aria-disabled={disabled}
className={cn( aria-selected={isActive}
"relative cursor-default outline-none", data-disabled={disabled}
"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2", 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]"
"bg-muted-foreground/5": isActive, onKeyDown={handleKeyDown}
"hover:bg-muted/50": !isActive
}
)}
onDoubleClick={handleRowDoubleClick}
> >
<Popover <div
open={openPopoverForId === personalLink.id} className={cn(
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)} "w-full grow overflow-visible outline-none",
> "flex items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2"
<PopoverTrigger asChild>
<Button size="sm" type="button" role="combobox" variant="secondary" className="size-7 shrink-0 p-0">
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
) : (
<LaIcon name="Circle" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink.learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2">
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 shrink-0 rounded-full"
width={16}
height={16}
/>
)} )}
<div className="flex min-w-0 flex-col items-start gap-y-1 overflow-hidden md:flex-row md:items-center md:gap-x-2"> >
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p> <Popover
open={openPopoverForId === personalLink.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? personalLink.id : null)}
>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className="size-7 shrink-0 p-0"
onClick={e => e.stopPropagation()}
onDoubleClick={e => e.stopPropagation()}
>
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={cn(selectedLearningState.className)} />
) : (
<LaIcon name="Circle" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={personalLink.learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
<div className="flex min-w-0 flex-col items-start gap-y-1.5 overflow-hidden md:flex-row md:items-center md:gap-x-2">
<div className="flex items-center gap-x-1">
{personalLink.icon && (
<Image
src={personalLink.icon}
alt={personalLink.title}
className="size-5 shrink-0 rounded-full"
width={16}
height={16}
/>
)}
<p className="text-primary hover:text-primary line-clamp-1 text-sm font-medium">{personalLink.title}</p>
</div>
{personalLink.url && ( {personalLink.url && (
<div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1"> <div className="text-muted-foreground flex min-w-0 shrink items-center gap-x-1">
<LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" /> <LaIcon name="Link" aria-hidden="true" className="size-3 flex-none" />
@@ -146,16 +156,20 @@ export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
</div> </div>
)} )}
</div> </div>
<div className="flex-1"></div>
<div className="flex shrink-0 items-center justify-end">
{personalLink.topic && (
<Badge variant="secondary" className="border-muted-foreground/25">
{personalLink.topic.prettyName}
</Badge>
)}
</div>
</div> </div>
<div className="flex shrink-0 items-center justify-end"> <div className="relative h-[0.5px] w-full after:absolute after:left-0 after:right-0 after:block after:h-full after:bg-[var(--link-border-after)]"></div>
{personalLink.topic && ( </div>
<Badge variant="secondary" className="border-muted-foreground/25">
{personalLink.topic.prettyName}
</Badge>
)}
</div>
</li>
) )
} }
) )
@@ -13,7 +13,7 @@ import { Editor } from "@tiptap/core"
import { generateUniqueSlug } from "@/lib/utils" import { generateUniqueSlug } from "@/lib/utils"
import { FocusClasses } from "@tiptap/extension-focus" import { FocusClasses } from "@tiptap/extension-focus"
import { DetailPageHeader } from "./header" import { DetailPageHeader } from "./header"
import { useMedia } from "react-use" import { useMedia } from "@/hooks/use-media"
import { TopicSelector } from "@/components/custom/topic-selector" import { TopicSelector } from "@/components/custom/topic-selector"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon" 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 = () => { export const useColumnStyles = () => {
const isTablet = useMedia("(max-width: 640px)") const isTablet = useMedia("(max-width: 640px)")
+2 -2
View File
@@ -4,7 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider"
import { useAtom } from "jotai" import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { PageItem } from "./partials/page-item" 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 { useColumnStyles } from "./hooks/use-column-styles"
import { PersonalPage, PersonalPageLists } from "@/lib/schema" import { PersonalPage, PersonalPageLists } from "@/lib/schema"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
@@ -89,7 +89,7 @@ interface PageListItemsProps {
} }
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => { const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex }) const { setElementRef } = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
return ( return (
<Primitive.div <Primitive.div
@@ -3,7 +3,7 @@ import Link from "next/link"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PersonalPage } from "@/lib/schema" import { PersonalPage } from "@/lib/schema"
import { Badge } from "@/components/ui/badge" 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 { useColumnStyles } from "../hooks/use-column-styles"
import { format } from "date-fns" import { format } from "date-fns"
import { Column } from "@/components/custom/column" import { Column } from "@/components/custom/column"
+17 -24
View File
@@ -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, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from "cmdk"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils" import { cn, searchSafeRegExp, shuffleArray } from "@/lib/utils"
import { useMountedState } from "react-use" import { useIsMounted } from "@/hooks/use-is-mounted"
interface GraphNode { interface GraphNode {
name: string name: string
@@ -18,16 +18,16 @@ interface AutocompleteProps {
} }
export function Autocomplete({ topics = [], onSelect, onInputChange }: AutocompleteProps): JSX.Element { export function Autocomplete({ topics = [], onSelect, onInputChange }: AutocompleteProps): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null) const inputRef = React.useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false) const [, setOpen] = React.useState(false)
const isMounted = useMountedState() const isMounted = useIsMounted()
const [inputValue, setInputValue] = useState("") const [inputValue, setInputValue] = React.useState("")
const [hasInteracted, setHasInteracted] = useState(false) const [hasInteracted, setHasInteracted] = React.useState(false)
const [showDropdown, setShowDropdown] = 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) { if (!inputValue) {
return initialShuffledTopics return initialShuffledTopics
} }
@@ -44,7 +44,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
.slice(0, 10) .slice(0, 10)
}, [inputValue, topics, initialShuffledTopics]) }, [inputValue, topics, initialShuffledTopics])
const handleSelect = useCallback( const handleSelect = React.useCallback(
(topic: GraphNode) => { (topic: GraphNode) => {
setOpen(false) setOpen(false)
onSelect(topic.name) onSelect(topic.name)
@@ -52,7 +52,7 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
[onSelect] [onSelect]
) )
const handleInputChange = useCallback( const handleInputChange = React.useCallback(
(value: string) => { (value: string) => {
setInputValue(value) setInputValue(value)
setShowDropdown(true) setShowDropdown(true)
@@ -62,34 +62,27 @@ export function Autocomplete({ topics = [], onSelect, onInputChange }: Autocompl
[onInputChange] [onInputChange]
) )
const handleFocus = useCallback(() => { const handleFocus = React.useCallback(() => {
setHasInteracted(true) setHasInteracted(true)
}, []) }, [])
const handleClick = useCallback(() => { const handleClick = React.useCallback(() => {
setShowDropdown(true) setShowDropdown(true)
setHasInteracted(true) setHasInteracted(true)
}, []) }, [])
const commandKey = useMemo(() => { const commandKey = React.useMemo(() => {
return filteredTopics return filteredTopics
.map(topic => `${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`) .map(topic => `${topic.name}:${topic.prettyName}:${topic.connectedTopics.join(",")}`)
.join("__") .join("__")
}, [filteredTopics]) }, [filteredTopics])
useEffect(() => { React.useEffect(() => {
if (inputRef.current && isMounted() && hasInteracted) { if (inputRef.current && isMounted() && hasInteracted) {
inputRef.current.focus() inputRef.current.focus()
} }
}, [commandKey, isMounted, hasInteracted]) }, [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 ( return (
<Command <Command
className={cn("relative mx-auto max-w-md overflow-visible shadow-md", { 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} onClick={handleClick}
placeholder={filteredTopics[0]?.prettyName} placeholder={filteredTopics[0]?.prettyName}
className={cn("placeholder:text-muted-foreground flex-1 bg-transparent px-2 outline-none")} className={cn("placeholder:text-muted-foreground flex-1 bg-transparent px-2 outline-none")}
autoFocus // Add this line autoFocus
/> />
</div> </div>
<div className="relative"> <div className="relative">
@@ -8,6 +8,7 @@ import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { LearningStateValue } from "@/lib/constants" import { LearningStateValue } from "@/lib/constants"
import { useClerk } from "@clerk/nextjs" import { useClerk } from "@clerk/nextjs"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useMedia } from "@/hooks/use-media"
interface TopicDetailHeaderProps { interface TopicDetailHeaderProps {
topic: Topic topic: Topic
@@ -16,6 +17,7 @@ interface TopicDetailHeaderProps {
export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) { export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }: TopicDetailHeaderProps) {
const clerk = useClerk() const clerk = useClerk()
const pathname = usePathname() const pathname = usePathname()
const isMobile = useMedia("(max-width: 770px)")
const { me } = useAccountOrGuest({ const { me } = useAccountOrGuest({
root: { root: {
topicsWantToLearn: [], topicsWantToLearn: [],
@@ -90,20 +92,19 @@ export const TopicDetailHeader = React.memo(function TopicDetailHeader({ topic }
return ( return (
<ContentHeader className="px-6 py-5 max-lg:px-4"> <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 /> <SidebarToggleButton />
<div className="flex min-h-0 items-center"> <div className="flex min-h-0 min-w-0 flex-1 items-center">
<span className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</span> <h1 className="truncate text-left font-bold lg:text-xl">{topic.prettyName}</h1>
</div> </div>
</div> </div>
<div className="flex flex-auto"></div>
<LearningStateSelector <LearningStateSelector
showSearch={false} showSearch={false}
value={p?.learningState || ""} value={p?.learningState || ""}
onChange={handleAddToProfile} onChange={handleAddToProfile}
defaultLabel="Add to my profile" defaultLabel={isMobile ? "" : "Add to profile"}
defaultIcon="Circle"
/> />
</ContentHeader> </ContentHeader>
) )
@@ -151,12 +151,7 @@ export const LinkItem = React.memo(
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent className="w-52 rounded-lg p-0" side="bottom" align="start">
className="w-52 rounded-lg p-0"
side="bottom"
align="start"
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent <LearningStateSelectorContent
showSearch={false} showSearch={false}
searchPlaceholder="Search state..." searchPlaceholder="Search state..."
@@ -1,4 +1,4 @@
import { useMedia } from "react-use" import { useMedia } from "@/hooks/use-media"
export const useColumnStyles = () => { export const useColumnStyles = () => {
const isTablet = useMedia("(max-width: 640px)") const isTablet = useMedia("(max-width: 640px)")
+2 -2
View File
@@ -4,7 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider"
import { atom, useAtom } from "jotai" import { atom, useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette" import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { TopicItem } from "./partials/topic-item" import { TopicItem } from "./partials/topic-item"
import { useMedia } from "react-use" import { useMedia } from "@/hooks/use-media"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll" import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { Column } from "@/components/custom/column" import { Column } from "@/components/custom/column"
@@ -132,7 +132,7 @@ interface TopicListItemsProps {
} }
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => { const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex }) const { setElementRef } = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
return ( return (
<Primitive.div <Primitive.div
@@ -139,7 +139,6 @@ export const TopicItem = React.forwardRef<HTMLDivElement, TopicItemProps>(({ top
side="bottom" side="bottom"
align="end" align="end"
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
onCloseAutoFocus={e => e.preventDefault()}
> >
<LearningStateSelectorContent <LearningStateSelectorContent
showSearch={false} showSearch={false}
+5 -4
View File
@@ -11,10 +11,11 @@ export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemSc
const { activeIndex } = options const { activeIndex } = options
const elementRefs = useRef<ElementRefs<T>>([]) const elementRefs = useRef<ElementRefs<T>>([])
const scrollActiveElementIntoView = useCallback((index: number) => { const scrollActiveElementIntoView = (index: number) => {
const activeElement = elementRefs.current[index] const activeElement = elementRefs.current[index]
activeElement?.scrollIntoView({ block: "nearest" }) activeElement?.focus()
}, []) // activeElement?.scrollIntoView({ block: "nearest" })
}
useEffect(() => { useEffect(() => {
if (activeIndex !== null) { if (activeIndex !== null) {
@@ -26,5 +27,5 @@ export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemSc
elementRefs.current[index] = element elementRefs.current[index] = element
}, []) }, [])
return setElementRef return { setElementRef, scrollActiveElementIntoView }
} }
+33
View 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
View 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
View 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
})
+12 -1
View File
@@ -2,7 +2,18 @@ import { useAtom } from "jotai"
import { useEffect, useCallback } from "react" import { useEffect, useCallback } from "react"
import { keyboardDisableSourcesAtom } from "@/store/keydown-manager" 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) { export function useKeyboardManager(sourceId: string) {
const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom) const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom)
-21
View File
@@ -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
View 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
View 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
View 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]
)
}
+27
View File
@@ -0,0 +1,27 @@
import { useState, useEffect } from "react"
const SSR = typeof window === "undefined"
export function useTouchSensor() {
const [isTouchDevice, setIsTouchDevice] = useState(false)
useEffect(() => {
const detectTouch = () => {
setIsTouchDevice(
!SSR &&
(window.matchMedia?.("(hover: none) and (pointer: coarse)")?.matches ||
"ontouchstart" in window ||
navigator.maxTouchPoints > 0)
)
}
detectTouch()
window.addEventListener("touchstart", detectTouch, false)
return () => {
window.removeEventListener("touchstart", detectTouch)
}
}, [])
return isTouchDevice
}
+12
View File
@@ -34,6 +34,18 @@ export function shuffleArray<T>(array: T[]): T[] {
return shuffled return shuffled
} }
const inputs = ["input", "select", "button", "textarea"] // detect if node is a text input element
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" export * from "./urls"
export * from "./slug" export * from "./slug"
export * from "./keyboard" export * from "./keyboard"
+40 -47
View File
@@ -1,47 +1,4 @@
let isMac: boolean | undefined const SSR = typeof window === "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
}
interface ShortcutKeyResult { interface ShortcutKeyResult {
symbol: string symbol: string
@@ -51,11 +8,11 @@ interface ShortcutKeyResult {
export function getShortcutKey(key: string): ShortcutKeyResult { export function getShortcutKey(key: string): ShortcutKeyResult {
const lowercaseKey = key.toLowerCase() const lowercaseKey = key.toLowerCase()
if (lowercaseKey === "mod") { 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") { } 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") { } else if (lowercaseKey === "shift") {
return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" } return isMac() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" }
} else { } else {
return { symbol: key.toUpperCase(), readable: key } return { symbol: key.toUpperCase(), readable: key }
} }
@@ -64,3 +21,39 @@ export function getShortcutKey(key: string): ShortcutKeyResult {
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] { export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
return keys.map(key => getShortcutKey(key)) 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
+126 -125
View File
@@ -1,127 +1,128 @@
{ {
"name": "web", "name": "web",
"version": "0.1.0", "version": "0.1.0",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^5.6.0", "@clerk/nextjs": "^5.6.0",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/modifiers": "^7.0.0",
"@hookform/resolvers": "^3.9.0", "@dnd-kit/sortable": "^8.0.0",
"@nothing-but/force-graph": "^0.9.5", "@hookform/resolvers": "^3.9.0",
"@nothing-but/utils": "^0.16.0", "@nothing-but/force-graph": "^0.9.5",
"@omit/react-confirm-dialog": "^1.1.5", "@nothing-but/utils": "^0.16.0",
"@omit/react-fancy-switch": "^0.1.3", "@omit/react-confirm-dialog": "^1.1.5",
"@radix-ui/react-alert-dialog": "^1.1.1", "@omit/react-fancy-switch": "^0.1.3",
"@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-context-menu": "^2.2.1", "@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dismissable-layer": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-dismissable-layer": "^1.1.0",
"@radix-ui/react-focus-scope": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-focus-scope": "^1.1.0",
"@radix-ui/react-label": "^2.1.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.0",
"@sentry/nextjs": "^8.30.0", "@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-virtual": "^3.10.8", "@sentry/nextjs": "^8.30.0",
"@tiptap/core": "^2.7.2", "@tanstack/react-virtual": "^3.10.8",
"@tiptap/extension-blockquote": "^2.7.2", "@tiptap/core": "^2.7.2",
"@tiptap/extension-bold": "^2.7.2", "@tiptap/extension-blockquote": "^2.7.2",
"@tiptap/extension-bullet-list": "^2.7.2", "@tiptap/extension-bold": "^2.7.2",
"@tiptap/extension-code": "^2.7.2", "@tiptap/extension-bullet-list": "^2.7.2",
"@tiptap/extension-code-block-lowlight": "^2.7.2", "@tiptap/extension-code": "^2.7.2",
"@tiptap/extension-color": "^2.7.2", "@tiptap/extension-code-block-lowlight": "^2.7.2",
"@tiptap/extension-document": "^2.7.2", "@tiptap/extension-color": "^2.7.2",
"@tiptap/extension-dropcursor": "^2.7.2", "@tiptap/extension-document": "^2.7.2",
"@tiptap/extension-focus": "^2.7.2", "@tiptap/extension-dropcursor": "^2.7.2",
"@tiptap/extension-gapcursor": "^2.7.2", "@tiptap/extension-focus": "^2.7.2",
"@tiptap/extension-hard-break": "^2.7.2", "@tiptap/extension-gapcursor": "^2.7.2",
"@tiptap/extension-heading": "^2.7.2", "@tiptap/extension-hard-break": "^2.7.2",
"@tiptap/extension-history": "^2.7.2", "@tiptap/extension-heading": "^2.7.2",
"@tiptap/extension-horizontal-rule": "^2.7.2", "@tiptap/extension-history": "^2.7.2",
"@tiptap/extension-image": "^2.7.2", "@tiptap/extension-horizontal-rule": "^2.7.2",
"@tiptap/extension-italic": "^2.7.2", "@tiptap/extension-image": "^2.7.2",
"@tiptap/extension-link": "^2.7.2", "@tiptap/extension-italic": "^2.7.2",
"@tiptap/extension-list-item": "^2.7.2", "@tiptap/extension-link": "^2.7.2",
"@tiptap/extension-ordered-list": "^2.7.2", "@tiptap/extension-list-item": "^2.7.2",
"@tiptap/extension-paragraph": "^2.7.2", "@tiptap/extension-ordered-list": "^2.7.2",
"@tiptap/extension-placeholder": "^2.7.2", "@tiptap/extension-paragraph": "^2.7.2",
"@tiptap/extension-strike": "^2.7.2", "@tiptap/extension-placeholder": "^2.7.2",
"@tiptap/extension-task-item": "^2.7.2", "@tiptap/extension-strike": "^2.7.2",
"@tiptap/extension-task-list": "^2.7.2", "@tiptap/extension-task-item": "^2.7.2",
"@tiptap/extension-text": "^2.7.2", "@tiptap/extension-task-list": "^2.7.2",
"@tiptap/extension-typography": "^2.7.2", "@tiptap/extension-text": "^2.7.2",
"@tiptap/pm": "^2.7.2", "@tiptap/extension-typography": "^2.7.2",
"@tiptap/react": "^2.7.2", "@tiptap/pm": "^2.7.2",
"@tiptap/starter-kit": "^2.7.2", "@tiptap/react": "^2.7.2",
"@tiptap/suggestion": "^2.7.2", "@tiptap/starter-kit": "^2.7.2",
"axios": "^1.7.7", "@tiptap/suggestion": "^2.7.2",
"cheerio": "1.0.0", "axios": "^1.7.7",
"class-variance-authority": "^0.7.0", "cheerio": "1.0.0",
"clsx": "^2.1.1", "class-variance-authority": "^0.7.0",
"cmdk": "^1.0.0", "clsx": "^2.1.1",
"date-fns": "^3.6.0", "cmdk": "^1.0.0",
"framer-motion": "^11.5.5", "date-fns": "^3.6.0",
"geist": "^1.3.1", "framer-motion": "^11.5.6",
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5", "geist": "^1.3.1",
"jazz-react": "0.7.35-guest-auth.5", "jazz-browser-auth-clerk": "0.8.0",
"jazz-react-auth-clerk": "0.7.35-guest-auth.5", "jazz-react": "0.8.0",
"jazz-tools": "0.7.35-guest-auth.5", "jazz-react-auth-clerk": "0.8.0",
"jotai": "^2.9.3", "jazz-tools": "0.8.0",
"lowlight": "^3.1.0", "jotai": "^2.10.0",
"lucide-react": "^0.429.0", "lowlight": "^3.1.0",
"next": "14.2.10", "lucide-react": "^0.429.0",
"next-themes": "^0.3.0", "next": "14.2.10",
"nuqs": "^1.19.1", "next-themes": "^0.3.0",
"react": "^18.3.1", "nuqs": "^1.19.1",
"react-day-picker": "^8.10.1", "query-string": "^9.1.0",
"react-dom": "^18.3.1", "react": "^18.3.1",
"react-hook-form": "^7.53.0", "react-day-picker": "^8.10.1",
"react-textarea-autosize": "^8.5.3", "react-dom": "^18.3.1",
"react-use": "^17.5.1", "react-hook-form": "^7.53.0",
"ronin": "^4.3.1", "react-textarea-autosize": "^8.5.3",
"slugify": "^1.6.6", "ronin": "^4.3.1",
"sonner": "^1.5.0", "slugify": "^1.6.6",
"streaming-markdown": "^0.0.14", "sonner": "^1.5.0",
"tailwind-merge": "^2.5.2", "streaming-markdown": "^0.0.14",
"tailwindcss-animate": "^1.0.7", "tailwind-merge": "^2.5.2",
"vaul": "^0.9.4", "tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8", "vaul": "^0.9.4",
"zsa": "^0.6.0", "zod": "^3.23.8",
"zsa-react": "^0.2.2" "zsa": "^0.6.0",
}, "zsa-react": "^0.2.3"
"devDependencies": { },
"@ronin/learn-anything": "^0.0.0-3452357373461", "devDependencies": {
"@testing-library/jest-dom": "^6.5.0", "@ronin/learn-anything": "0.0.0-3452357373461",
"@testing-library/react": "^16.0.1", "@testing-library/jest-dom": "^6.5.0",
"@types/jest": "^29.5.13", "@testing-library/react": "^16.0.1",
"@types/node": "^22.5.5", "@types/jest": "^29.5.13",
"@types/react": "^18.3.7", "@types/node": "^22.5.5",
"@types/react-dom": "^18.3.0", "@types/react": "^18.3.8",
"dotenv": "^16.4.5", "@types/react-dom": "^18.3.0",
"eslint": "^8.57.1", "dotenv": "^16.4.5",
"eslint-config-next": "14.2.5", "eslint": "^8.57.1",
"jest": "^29.7.0", "eslint-config-next": "14.2.5",
"jest-environment-jsdom": "^29.7.0", "jest": "^29.7.0",
"postcss": "^8.4.47", "jest-environment-jsdom": "^29.7.0",
"prettier-plugin-tailwindcss": "^0.6.6", "postcss": "^8.4.47",
"tailwindcss": "^3.4.12", "prettier-plugin-tailwindcss": "^0.6.6",
"ts-jest": "^29.2.5", "tailwindcss": "^3.4.12",
"ts-node": "^10.9.2", "ts-jest": "^29.2.5",
"typescript": "^5.6.2" "ts-node": "^10.9.2",
} "typescript": "^5.6.2"
}
} }