Merge branch 'main' into tasks

This commit is contained in:
Nikita
2024-09-19 19:38:31 +03:00
56 changed files with 1889 additions and 1257 deletions

View File

@@ -14,14 +14,14 @@
"web"
],
"dependencies": {
"@clerk/themes": "^2.1.27",
"@tauri-apps/cli": "^2.0.0-rc.12",
"@clerk/themes": "^2.1.30",
"@tauri-apps/cli": "^2.0.0-rc.16",
"@tauri-apps/plugin-fs": "^2.0.0-rc.2",
"jazz-nodejs": "0.7.35-guest-auth.5",
"react-icons": "^5.3.0"
},
"devDependencies": {
"bun-types": "^1.1.27"
"bun-types": "^1.1.28"
},
"prettier": {
"plugins": [

View File

@@ -1,19 +1,27 @@
"use client"
import type { Viewport } from "next"
import { Sidebar } from "@/components/custom/sidebar/sidebar"
import { CommandPalette } from "@/components/custom/command-palette/command-palette"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
import { Shortcut } from "@/components/custom/Shortcut/shortcut"
import { GlobalKeydownHandler } from "@/components/custom/global-keydown-handler"
export const viewport: Viewport = {
width: "device-width, shrink-to-fit=no",
maximumScale: 1,
userScalable: false
}
export default function PageLayout({ children }: { children: React.ReactNode }) {
const { me } = useAccountOrGuest()
return (
<div className="flex h-full min-h-full w-full flex-row items-stretch overflow-hidden">
<Sidebar />
<LearnAnythingOnboarding />
<GlobalKeydownHandler />
{me._type !== "Anonymous" && <CommandPalette />}
<CommandPalette />
<Shortcut />
<div className="flex min-w-0 flex-1 flex-col">
<div className="relative flex min-w-0 flex-1 flex-col">
<main className="relative flex flex-auto flex-col place-items-stretch overflow-auto lg:my-2 lg:mr-2 lg:rounded-md lg:border">
{children}
</main>

View File

@@ -0,0 +1,5 @@
import { TopicRoute } from "@/components/routes/topics/TopicRoute"
export default function Page() {
return <TopicRoute />
}

View File

@@ -1,4 +1,4 @@
import type { Metadata } from "next"
import type { Metadata, Viewport } from "next"
import { cn } from "@/lib/utils"
import { ThemeProvider } from "@/lib/providers/theme-provider"
import "./globals.css"
@@ -10,7 +10,13 @@ import { DeepLinkProvider } from "@/lib/providers/deep-link-provider"
import { GeistMono, GeistSans } from "./fonts"
import { JazzAndAuth } from "@/lib/providers/jazz-provider"
import { TooltipProvider } from "@/components/ui/tooltip"
import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding"
export const viewport: Viewport = {
width: "device-width",
height: "device-height",
initialScale: 1,
viewportFit: "cover"
}
export const metadata: Metadata = {
title: "Learn Anything",
@@ -43,7 +49,7 @@ export default function RootLayout({
<body className={cn("h-full w-full font-sans antialiased", GeistSans.variable, GeistMono.variable)}>
<Providers>
{children}
<LearnAnythingOnboarding />
<Toaster expand={false} />
</Providers>
</body>

View File

@@ -0,0 +1,156 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { atom, useAtom } from "jotai"
import { Sheet, SheetPortal, SheetOverlay, SheetTitle, sheetVariants, SheetDescription } from "@/components/ui/sheet"
import { LaIcon } from "../la-icon"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
export const showShortcutAtom = atom(false)
type ShortcutItem = {
label: string
keys: string[]
then?: string[]
}
type ShortcutSection = {
title: string
shortcuts: ShortcutItem[]
}
const SHORTCUTS: ShortcutSection[] = [
{
title: "General",
shortcuts: [
{ label: "Open command menu", keys: ["⌘", "k"] },
{ label: "Log out", keys: ["⌥", "⇧", "q"] }
]
},
{
title: "Navigation",
shortcuts: [
{ label: "Go to link", keys: ["G"], then: ["L"] },
{ label: "Go to page", keys: ["G"], then: ["P"] },
{ label: "Go to topic", keys: ["G"], then: ["T"] }
]
}
]
const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => (
<kbd
aria-hidden="true"
className="inline-flex size-5 items-center justify-center rounded border font-sans text-xs capitalize"
>
{keyChar}
</kbd>
)
const ShortcutItem: React.FC<ShortcutItem> = ({ label, keys, then }) => (
<div className="flex flex-row items-center gap-2">
<dt className="flex grow items-center">
<span className="text-muted-foreground text-left text-sm">{label}</span>
</dt>
<dd className="flex items-end">
<span className="text-left">
<span
aria-label={keys.join(" ") + (then ? ` then ${then.join(" ")}` : "")}
className="inline-flex items-center gap-1"
>
{keys.map((key, index) => (
<ShortcutKey key={index} keyChar={key} />
))}
{then && (
<>
<span className="text-muted-foreground text-xs">then</span>
{then.map((key, index) => (
<ShortcutKey key={`then-${index}`} keyChar={key} />
))}
</>
)}
</span>
</span>
</dd>
</div>
)
const ShortcutSection: React.FC<ShortcutSection> = ({ title, shortcuts }) => (
<section className="flex flex-col gap-2">
<h2 className="inline-flex gap-1.5 text-sm">{title}</h2>
<dl className="m-0 flex flex-col gap-2">
{shortcuts.map((shortcut, index) => (
<ShortcutItem key={index} {...shortcut} />
))}
</dl>
</section>
)
export function Shortcut() {
const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom)
const [searchQuery, setSearchQuery] = React.useState("")
const { disableKeydown } = useKeyboardManager("shortcutSection")
React.useEffect(() => {
disableKeydown(showShortcut)
}, [showShortcut, disableKeydown])
const filteredShortcuts = React.useMemo(() => {
if (!searchQuery) return SHORTCUTS
return SHORTCUTS.map(section => ({
...section,
shortcuts: section.shortcuts.filter(shortcut => shortcut.label.toLowerCase().includes(searchQuery.toLowerCase()))
})).filter(section => section.shortcuts.length > 0)
}, [searchQuery])
return (
<Sheet open={showShortcut} onOpenChange={setShowShortcut}>
<SheetPortal>
<SheetOverlay className="bg-black/10" />
<SheetPrimitive.Content
className={cn(sheetVariants({ side: "right" }), "m-3 h-[calc(100vh-24px)] rounded-md p-0")}
>
<header className="flex flex-[0_0_auto] items-center gap-3 px-5 pb-4 pt-5">
<SheetTitle className="text-base font-medium">Keyboard Shortcuts</SheetTitle>
<SheetDescription className="sr-only">Quickly navigate around the app</SheetDescription>
<div className="flex-auto"></div>
<SheetPrimitive.Close className={cn(buttonVariants({ size: "icon", variant: "ghost" }), "size-6 p-0")}>
<LaIcon name="X" className="text-muted-foreground size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</header>
<div className="flex flex-col gap-1 px-5 pb-6">
<form className="relative flex items-center">
<LaIcon name="Search" className="text-muted-foreground absolute left-3 size-4" />
<Input
autoFocus
placeholder="Search shortcuts"
className="border-muted-foreground/50 focus-visible:border-muted-foreground h-10 pl-10 focus-visible:ring-0"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</form>
</div>
<main className="flex-auto overflow-y-auto overflow-x-hidden [scrollbar-gutter:auto]">
<div className="px-5 pb-5">
<div role="region" aria-live="polite" className="flex flex-col gap-7">
{filteredShortcuts.map((section, index) => (
<ShortcutSection key={index} {...section} />
))}
</div>
</div>
</main>
</SheetPrimitive.Content>
</SheetPortal>
</Sheet>
)
}

View File

@@ -6,6 +6,7 @@ import { HTMLLikeElement } from "@/lib/utils"
export type CommandAction = string | (() => void)
export interface CommandItemType {
id?: string
icon?: keyof typeof icons
value: string
label: HTMLLikeElement | string

View File

@@ -11,14 +11,14 @@ export interface CommandItemProps extends Omit<CommandItemType, "action"> {
}
const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = React.memo(({ content }) => {
return <>{renderHTMLLikeElement(content)}</>
return <span className="line-clamp-1">{renderHTMLLikeElement(content)}</span>
})
HTMLLikeRenderer.displayName = "HTMLLikeRenderer"
export const CommandItem: React.FC<CommandItemProps> = React.memo(
({ icon, label, action, payload, shortcut, handleAction }) => (
<Command.Item onSelect={() => handleAction(action, payload)}>
({ icon, label, action, payload, shortcut, handleAction, ...item }) => (
<Command.Item value={`${item.id}-${item.value}`} onSelect={() => handleAction(action, payload)}>
{icon && <LaIcon name={icon} />}
<HTMLLikeRenderer content={label} />
{shortcut && <CommandShortcut>{shortcut}</CommandShortcut>}

View File

@@ -1,14 +1,17 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Command } from "cmdk"
import { Dialog, DialogPortal, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { CommandGroup } from "./command-items"
import { CommandAction, CommandItemType, createCommandGroups } from "./command-data"
import { useAccount } from "@/lib/providers/jazz-provider"
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { searchSafeRegExp } from "@/lib/utils"
import { GraphNode } from "@/components/routes/public/PublicHomeRoute"
import { useCommandActions } from "./hooks/use-command-actions"
import { atom, useAtom } from "jotai"
import { useKeydownListener } from "@/hooks/use-keydown-listener"
const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
@@ -18,6 +21,14 @@ const filterItems = (items: CommandItemType[], searchRegex: RegExp) =>
export const commandPaletteOpenAtom = atom(false)
export function CommandPalette() {
const { me } = useAccountOrGuest()
if (me._type === "Anonymous") return null
return <RealCommandPalette />
}
export function RealCommandPalette() {
const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } })
const dialogRef = React.useRef<HTMLDivElement | null>(null)
const [inputValue, setInputValue] = React.useState("")
@@ -29,17 +40,17 @@ export function CommandPalette() {
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
const handleKeydown = React.useCallback(
(e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(prev => !prev)
}
}
},
[setOpen]
)
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [setOpen])
useKeydownListener(handleKeydown)
const bounce = React.useCallback(() => {
if (dialogRef.current) {
@@ -86,6 +97,7 @@ export function CommandPalette() {
heading: "Personal Links",
items:
me?.root.personalLinks?.map(link => ({
id: link?.id,
icon: "Link" as const,
value: link?.title || "Untitled",
label: link?.title || "Untitled",
@@ -100,6 +112,7 @@ export function CommandPalette() {
heading: "Personal Pages",
items:
me?.root.personalPages?.map(page => ({
id: page?.id,
icon: "FileText" as const,
value: page?.title || "Untitled",
label: page?.title || "Untitled",
@@ -116,11 +129,9 @@ export function CommandPalette() {
if (activePage === "home") {
if (!inputValue) {
// Only show items from the home object when there's no search input
return commandGroups.home
}
// When there's a search input, search across all categories
const allGroups = [...Object.values(commandGroups).flat(), personalLinks, personalPages, topics]
return allGroups
@@ -131,7 +142,6 @@ export function CommandPalette() {
.filter(group => group.items.length > 0)
}
// Handle other active pages (searchLinks, searchPages, etc.)
switch (activePage) {
case "searchLinks":
return [...commandGroups.searchLinks, { items: filterItems(personalLinks.items, searchRegex) }]
@@ -184,7 +194,7 @@ export function CommandPalette() {
const commandKey = React.useMemo(() => {
return filteredCommands
.map(group => {
const itemsKey = group.items.map(item => `${item.label}-${item.action}`).join("|")
const itemsKey = group.items.map(item => `${item.label}-${item.value}`).join("|")
return `${group.heading}:${itemsKey}`
})
.join("__")

View File

@@ -3,11 +3,13 @@ import { ensureUrlProtocol } from "@/lib/utils"
import { useTheme } from "next-themes"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { LaAccount, PersonalPage } from "@/lib/schema"
import { LaAccount } from "@/lib/schema"
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
export const useCommandActions = () => {
const { setTheme } = useTheme()
const router = useRouter()
const { newPage } = usePageActions()
const changeTheme = React.useCallback(
(theme: string) => {
@@ -35,19 +37,10 @@ export const useCommandActions = () => {
const createNewPage = React.useCallback(
(me: LaAccount) => {
try {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
const page = newPage(me)
router.push(`/pages/${page.id}`)
},
[router]
[router, newPage]
)
return {

View File

@@ -0,0 +1,23 @@
export const DiscordIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.9143 7.38378L4.93679 14.6174C4.82454 15.448 5.24219 16.2606 5.983 16.6528L8.99995 18.25L9.99995 15.75C9.99995 15.75 10.6562 16.25 11.9999 16.25C13.3437 16.25 13.9999 15.75 13.9999 15.75L14.9999 18.25L18.0169 16.6528C18.7577 16.2606 19.1754 15.448 19.0631 14.6174L18.0856 7.38378C18.0334 6.99739 17.7613 6.67658 17.3887 6.56192L14.7499 5.75003V6.25003C14.7499 6.80232 14.3022 7.25003 13.7499 7.25003H10.2499C9.69766 7.25003 9.24995 6.80232 9.24995 6.25003V5.75003L6.61122 6.56192C6.23855 6.67658 5.96652 6.99739 5.9143 7.38378Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M10.5 12C10.5 12.2761 10.2761 12.5 10 12.5C9.72386 12.5 9.5 12.2761 9.5 12C9.5 11.7239 9.72386 11.5 10 11.5C10.2761 11.5 10.5 11.7239 10.5 12Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<path
d="M14.5 12C14.5 12.2761 14.2761 12.5 14 12.5C13.7239 12.5 13.5 12.2761 13.5 12C13.5 11.7239 13.7239 11.5 14 11.5C14.2761 11.5 14.5 11.7239 14.5 12Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
></path>
</svg>
)

View File

@@ -0,0 +1,63 @@
"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
}

View File

@@ -20,7 +20,7 @@ interface LearningStateSelectorProps {
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
showSearch = true,
defaultLabel = "Select state",
defaultLabel = "State",
searchPlaceholder = "Search state...",
value,
onChange,

View File

@@ -7,7 +7,6 @@ import { atomWithStorage } from "jotai/utils"
import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"
import { toast } from "sonner"
import Link from "next/link"
import {
DropdownMenu,
@@ -21,6 +20,7 @@ import {
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { icons } from "lucide-react"
import { usePageActions } from "@/components/routes/page/hooks/use-page-actions"
type SortOption = "title" | "recent"
type ShowOption = 5 | 10 | 15 | 20 | 0
@@ -101,20 +101,13 @@ const PageSectionHeader: React.FC<PageSectionHeaderProps> = ({ pageCount, isActi
const NewPageButton: React.FC = () => {
const { me } = useAccount()
const router = useRouter()
const { newPage } = usePageActions()
if (!me) return null
const handleClick = () => {
try {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
const page = newPage(me)
router.push(`/pages/${page.id}`)
}
return (

View File

@@ -1,6 +1,14 @@
import { LaIcon } from "@/components/custom/la-icon"
import { useState } from "react"
"use client"
import { useEffect, useState } from "react"
import { SignInButton, useAuth, useUser } from "@clerk/nextjs"
import { useAtom } from "jotai"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { icons } from "lucide-react"
import { LaIcon } from "@/components/custom/la-icon"
import { DiscordIcon } from "@/components/custom/discordIcon"
import {
DropdownMenu,
DropdownMenuContent,
@@ -9,17 +17,25 @@ import {
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Avatar, AvatarImage } from "@/components/ui/avatar"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { Feedback } from "./feedback"
import { showShortcutAtom } from "@/components/custom/Shortcut/shortcut"
import { ShortcutKey } from "@/components/minimal-tiptap/components/shortcut-key"
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
export const ProfileSection: React.FC = () => {
const { user, isSignedIn } = useUser()
const { signOut } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
const pathname = usePathname()
const [, setShowShortcut] = useAtom(showShortcutAtom)
const { disableKeydown } = useKeyboardManager("profileSection")
useEffect(() => {
disableKeydown(menuOpen)
}, [menuOpen, disableKeydown])
if (!isSignedIn) {
return (
@@ -37,78 +53,104 @@ export const ProfileSection: React.FC = () => {
return (
<div className="flex flex-col gap-px border-t border-transparent px-3 py-2 pb-3 pt-1.5">
<div className="flex h-10 min-w-full items-center">
<div className="flex min-w-0">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
aria-label="Profile"
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
>
<Avatar className="size-6">
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
</Avatar>
<span className="truncate text-left text-sm font-medium -tracking-wider">{user.fullName}</span>
<LaIcon
name="ChevronDown"
className={cn("size-4 shrink-0 transition-transform duration-300", {
"rotate-180": menuOpen
})}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start" side="top">
<DropdownMenuItem asChild>
<Link className="cursor-pointer" href="/profile">
<div className="relative flex flex-1 items-center gap-2">
<LaIcon name="CircleUser" />
<span className="line-clamp-1 flex-1">My profile</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link className="cursor-pointer" href="/onboarding">
<div className="relative flex flex-1 items-center gap-2">
<LaIcon name="LayoutList" />
<span className="line-clamp-1 flex-1">Onboarding</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link className="cursor-pointer" href="https://docs.learn-anything.xyz/">
<div className="relative flex flex-1 items-center gap-2">
<LaIcon name="Sticker" />
<span className="line-clamp-1 flex-1">Docs</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link className="cursor-pointer" href="https://github.com/learn-anything/learn-anything">
<div className="relative flex flex-1 items-center gap-2">
<LaIcon name="Github" />
<span className="line-clamp-1 flex-1">GitHub</span>
</div>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut()}>
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
<LaIcon name="LogOut" />
<span className="line-clamp-1 flex-1">Log out</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<ProfileDropdown
user={user}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
signOut={signOut}
setShowShortcut={setShowShortcut}
/>
<Feedback />
</div>
</div>
)
}
interface ProfileDropdownProps {
user: any
menuOpen: boolean
setMenuOpen: (open: boolean) => void
signOut: () => void
setShowShortcut: (show: boolean) => void
}
const ProfileDropdown: React.FC<ProfileDropdownProps> = ({ user, menuOpen, setMenuOpen, signOut, setShowShortcut }) => (
<div className="flex min-w-0">
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
aria-label="Profile"
className="hover:bg-accent focus-visible:ring-ring hover:text-accent-foreground flex h-auto items-center gap-1.5 truncate rounded py-1 pl-1 pr-1.5 focus-visible:outline-none focus-visible:ring-1"
>
<Avatar className="size-6">
<AvatarImage src={user.imageUrl} alt={user.fullName || ""} />
</Avatar>
<span className="truncate text-left text-sm font-medium -tracking-wider">{user.fullName}</span>
<LaIcon
name="ChevronDown"
className={cn("size-4 shrink-0 transition-transform duration-300", {
"rotate-180": menuOpen
})}
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start" side="top">
<DropdownMenuItems signOut={signOut} setShowShortcut={setShowShortcut} />
</DropdownMenuContent>
</DropdownMenu>
</div>
)
interface DropdownMenuItemsProps {
signOut: () => void
setShowShortcut: (show: boolean) => void
}
const DropdownMenuItems: React.FC<DropdownMenuItemsProps> = ({ signOut, setShowShortcut }) => (
<>
<MenuLink href="/profile" icon="CircleUser" text="My profile" />
<DropdownMenuItem className="gap-2" onClick={() => setShowShortcut(true)}>
<LaIcon name="Keyboard" />
<span>Shortcut</span>
</DropdownMenuItem>
<MenuLink href="/onboarding" icon="LayoutList" text="Onboarding" />
<DropdownMenuSeparator />
<MenuLink href="https://docs.learn-anything.xyz/" icon="Sticker" text="Docs" />
<MenuLink href="https://github.com/learn-anything/learn-anything" icon="Github" text="GitHub" />
<MenuLink href="https://discord.com/invite/bxtD8x6aNF" icon={DiscordIcon} text="Discord" iconClass="-ml-1" />
<DropdownMenuSeparator />
<DropdownMenuItem onClick={signOut}>
<div className="relative flex flex-1 cursor-pointer items-center gap-2">
<LaIcon name="LogOut" />
<span>Log out</span>
<div className="absolute right-0">
<ShortcutKey keys={["alt", "shift", "q"]} />
</div>
</div>
</DropdownMenuItem>
</>
)
interface MenuLinkProps {
href: string
icon: keyof typeof icons | React.FC
text: string
iconClass?: string
}
const MenuLink: React.FC<MenuLinkProps> = ({ href, icon, text, iconClass = "" }) => {
const IconComponent = typeof icon === "string" ? icons[icon] : icon
return (
<DropdownMenuItem asChild>
<Link className="cursor-pointer" href={href}>
<div className={cn("relative flex flex-1 items-center gap-2", iconClass)}>
<IconComponent className="size-4" />
<span className="line-clamp-1 flex-1">{text}</span>
</div>
</Link>
</DropdownMenuItem>
)
}
export default ProfileSection

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { getShortcutKey } from "../../lib/utils"
import { getShortcutKey } from "@/lib/utils"
export interface ShortcutKeyWrapperProps extends React.HTMLAttributes<HTMLSpanElement> {
ariaLabel: string
@@ -32,7 +32,7 @@ const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ class
{...props}
ref={ref}
>
{getShortcutKey(shortcut)}
{getShortcutKey(shortcut).symbol}
</kbd>
)
})

View File

@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { Command, MenuListProps } from "./types"
import { getShortcutKeys } from "../../lib/utils"
import { getShortcutKeys } from "@/lib/utils"
import { Icon } from "../../components/ui/icon"
import { PopoverWrapper } from "../../components/ui/popover-wrapper"
import { Shortcut } from "../../components/ui/shortcut"
@@ -136,7 +136,11 @@ export const MenuList = React.forwardRef((props: MenuListProps, ref) => {
<Icon name={command.iconName} />
<span className="truncate text-sm">{command.label}</span>
<div className="flex flex-auto flex-row"></div>
<Shortcut.Wrapper ariaLabel={getShortcutKeys(command.shortcuts)}>
<Shortcut.Wrapper
ariaLabel={getShortcutKeys(command.shortcuts)
.map(shortcut => shortcut.readable)
.join(" + ")}
>
{command.shortcuts.map(shortcut => (
<Shortcut.Key shortcut={shortcut} key={shortcut} />
))}

View File

@@ -8,7 +8,5 @@ export function getOutput(editor: Editor, output: LAEditorProps["output"]) {
return ""
}
export * from "./keyboard"
export * from "./platform"
export * from "./isCustomNodeSelected"
export * from "./isTextSelected"

View File

@@ -1,25 +0,0 @@
import { isMacOS } from "./platform"
export const getShortcutKey = (key: string) => {
const lowercaseKey = key.toLowerCase()
const macOS = isMacOS()
switch (lowercaseKey) {
case "mod":
return macOS ? "⌘" : "Ctrl"
case "alt":
return macOS ? "⌥" : "Alt"
case "shift":
return macOS ? "⇧" : "Shift"
default:
return key
}
}
export const getShortcutKeys = (keys: string | string[], separator: string = "") => {
const keyArray = Array.isArray(keys) ? keys : keys.split(/\s+/)
const shortcutKeys = keyArray.map(getShortcutKey)
return shortcutKeys.join(separator)
}
export default { getShortcutKey, getShortcutKeys }

View File

@@ -1,46 +0,0 @@
export interface NavigatorWithUserAgentData extends Navigator {
userAgentData?: {
brands: { brand: string; version: string }[]
mobile: boolean
platform: string
getHighEntropyValues: (hints: string[]) => Promise<{
platform: string
platformVersion: string
uaFullVersion: string
}>
}
}
let isMac: boolean | undefined
const getPlatform = () => {
const nav = navigator as NavigatorWithUserAgentData
if (nav.userAgentData) {
if (nav.userAgentData.platform) {
return nav.userAgentData.platform
}
nav.userAgentData
.getHighEntropyValues(["platform"])
.then(highEntropyValues => {
if (highEntropyValues.platform) {
return highEntropyValues.platform
}
})
.catch(() => {
return navigator.platform || ""
})
}
return navigator.platform || ""
}
export const isMacOS = () => {
if (isMac === undefined) {
isMac = getPlatform().toLowerCase().includes("mac")
}
return isMac
}
export default isMacOS

View File

@@ -1,33 +1,33 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
import { getShortcutKey } from '../utils'
import * as React from "react"
import { cn } from "@/lib/utils"
import { getShortcutKey } from "@/lib/utils"
export interface ShortcutKeyProps extends React.HTMLAttributes<HTMLSpanElement> {
keys: string[]
keys: string[]
}
export const ShortcutKey = React.forwardRef<HTMLSpanElement, ShortcutKeyProps>(({ className, keys, ...props }, ref) => {
const modifiedKeys = keys.map(key => getShortcutKey(key))
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(' + ')
const modifiedKeys = keys.map(key => getShortcutKey(key))
const ariaLabel = modifiedKeys.map(shortcut => shortcut.readable).join(" + ")
return (
<span aria-label={ariaLabel} className={cn('inline-flex items-center gap-0.5', className)} {...props} ref={ref}>
{modifiedKeys.map(shortcut => (
<kbd
key={shortcut.symbol}
className={cn(
'inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]',
return (
<span aria-label={ariaLabel} className={cn("inline-flex items-center gap-0.5", className)} {...props} ref={ref}>
{modifiedKeys.map(shortcut => (
<kbd
key={shortcut.symbol}
className={cn(
"inline-block min-w-2.5 text-center align-baseline font-sans text-xs font-medium capitalize text-[rgb(156,157,160)]",
className
)}
{...props}
ref={ref}
>
{shortcut.symbol}
</kbd>
))}
</span>
)
className
)}
{...props}
ref={ref}
>
{shortcut.symbol}
</kbd>
))}
</span>
)
})
ShortcutKey.displayName = 'ShortcutKey'
ShortcutKey.displayName = "ShortcutKey"

View File

@@ -1,112 +1,112 @@
import * as React from 'react'
import type { Editor } from '@tiptap/react'
import { cn } from '@/lib/utils'
import { CaretDownIcon } from '@radix-ui/react-icons'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { ToolbarButton } from './toolbar-button'
import { ShortcutKey } from './shortcut-key'
import { getShortcutKey } from '../utils'
import type { FormatAction } from '../types'
import type { VariantProps } from 'class-variance-authority'
import type { toggleVariants } from '@/components/ui/toggle'
import * as React from "react"
import type { Editor } from "@tiptap/react"
import { cn } from "@/lib/utils"
import { CaretDownIcon } from "@radix-ui/react-icons"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { ToolbarButton } from "./toolbar-button"
import { ShortcutKey } from "./shortcut-key"
import { getShortcutKey } from "@/lib/utils"
import type { FormatAction } from "../types"
import type { VariantProps } from "class-variance-authority"
import type { toggleVariants } from "@/components/ui/toggle"
interface ToolbarSectionProps extends VariantProps<typeof toggleVariants> {
editor: Editor
actions: FormatAction[]
activeActions?: string[]
mainActionCount?: number
dropdownIcon?: React.ReactNode
dropdownTooltip?: string
dropdownClassName?: string
editor: Editor
actions: FormatAction[]
activeActions?: string[]
mainActionCount?: number
dropdownIcon?: React.ReactNode
dropdownTooltip?: string
dropdownClassName?: string
}
export const ToolbarSection: React.FC<ToolbarSectionProps> = ({
editor,
actions,
activeActions = actions.map(action => action.value),
mainActionCount = 0,
dropdownIcon,
dropdownTooltip = 'More options',
dropdownClassName = 'w-12',
size,
variant
editor,
actions,
activeActions = actions.map(action => action.value),
mainActionCount = 0,
dropdownIcon,
dropdownTooltip = "More options",
dropdownClassName = "w-12",
size,
variant
}) => {
const { mainActions, dropdownActions } = React.useMemo(() => {
const sortedActions = actions
.filter(action => activeActions.includes(action.value))
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
const { mainActions, dropdownActions } = React.useMemo(() => {
const sortedActions = actions
.filter(action => activeActions.includes(action.value))
.sort((a, b) => activeActions.indexOf(a.value) - activeActions.indexOf(b.value))
return {
mainActions: sortedActions.slice(0, mainActionCount),
dropdownActions: sortedActions.slice(mainActionCount)
}
}, [actions, activeActions, mainActionCount])
return {
mainActions: sortedActions.slice(0, mainActionCount),
dropdownActions: sortedActions.slice(mainActionCount)
}
}, [actions, activeActions, mainActionCount])
const renderToolbarButton = React.useCallback(
(action: FormatAction) => (
<ToolbarButton
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
isActive={action.isActive(editor)}
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(' ')}`}
aria-label={action.label}
size={size}
variant={variant}
>
{action.icon}
</ToolbarButton>
),
[editor, size, variant]
)
const renderToolbarButton = React.useCallback(
(action: FormatAction) => (
<ToolbarButton
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
isActive={action.isActive(editor)}
tooltip={`${action.label} ${action.shortcuts.map(s => getShortcutKey(s).symbol).join(" ")}`}
aria-label={action.label}
size={size}
variant={variant}
>
{action.icon}
</ToolbarButton>
),
[editor, size, variant]
)
const renderDropdownMenuItem = React.useCallback(
(action: FormatAction) => (
<DropdownMenuItem
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
className={cn('flex flex-row items-center justify-between gap-4', {
'bg-accent': action.isActive(editor)
})}
aria-label={action.label}
>
<span className="grow">{action.label}</span>
<ShortcutKey keys={action.shortcuts} />
</DropdownMenuItem>
),
[editor]
)
const renderDropdownMenuItem = React.useCallback(
(action: FormatAction) => (
<DropdownMenuItem
key={action.label}
onClick={() => action.action(editor)}
disabled={!action.canExecute(editor)}
className={cn("flex flex-row items-center justify-between gap-4", {
"bg-accent": action.isActive(editor)
})}
aria-label={action.label}
>
<span className="grow">{action.label}</span>
<ShortcutKey keys={action.shortcuts} />
</DropdownMenuItem>
),
[editor]
)
const isDropdownActive = React.useMemo(
() => dropdownActions.some(action => action.isActive(editor)),
[dropdownActions, editor]
)
const isDropdownActive = React.useMemo(
() => dropdownActions.some(action => action.isActive(editor)),
[dropdownActions, editor]
)
return (
<>
{mainActions.map(renderToolbarButton)}
{dropdownActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={isDropdownActive}
tooltip={dropdownTooltip}
aria-label={dropdownTooltip}
className={cn(dropdownClassName)}
size={size}
variant={variant}
>
{dropdownIcon || <CaretDownIcon className="size-5" />}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{dropdownActions.map(renderDropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
return (
<>
{mainActions.map(renderToolbarButton)}
{dropdownActions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ToolbarButton
isActive={isDropdownActive}
tooltip={dropdownTooltip}
aria-label={dropdownTooltip}
className={cn(dropdownClassName)}
size={size}
variant={variant}
>
{dropdownIcon || <CaretDownIcon className="size-5" />}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
{dropdownActions.map(renderDropdownMenuItem)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
}
export default ToolbarSection

View File

@@ -1,81 +1,14 @@
import type { Editor } from '@tiptap/core'
import type { MinimalTiptapProps } from './minimal-tiptap'
import type { Editor } from "@tiptap/core"
import type { MinimalTiptapProps } from "./minimal-tiptap"
let isMac: boolean | undefined
export function getOutput(editor: Editor, format: MinimalTiptapProps["output"]) {
if (format === "json") {
return editor.getJSON()
}
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 {
symbol: string
readable: string
}
export function getShortcutKey(key: string): ShortcutKeyResult {
const lowercaseKey = key.toLowerCase()
if (lowercaseKey === 'mod') {
return isMacOS() ? { symbol: '⌘', readable: 'Command' } : { symbol: 'Ctrl', readable: 'Control' }
} else if (lowercaseKey === 'alt') {
return isMacOS() ? { symbol: '⌥', readable: 'Option' } : { symbol: 'Alt', readable: 'Alt' }
} else if (lowercaseKey === 'shift') {
return isMacOS() ? { symbol: '⇧', readable: 'Shift' } : { symbol: 'Shift', readable: 'Shift' }
} else {
return { symbol: key, readable: key }
}
}
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
return keys.map(key => getShortcutKey(key))
}
export function getOutput(editor: Editor, format: MinimalTiptapProps['output']) {
if (format === 'json') {
return editor.getJSON()
}
if (format === 'html') {
return editor.getText() ? editor.getHTML() : ''
}
return editor.getText()
if (format === "html") {
return editor.getText() ? editor.getHTML() : ""
}
return editor.getText()
}

View File

@@ -8,11 +8,12 @@ import { parseAsBoolean, useQueryState } from "nuqs"
import { atom, useAtom } from "jotai"
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 function LinkRoute(): React.ReactElement {
const [nuqsEditId] = useQueryState("editId")
const [nuqsEditId, setNuqsEditId] = useQueryState("editId")
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
const [isInCreateMode] = useQueryState("create", parseAsBoolean)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
@@ -50,8 +51,13 @@ export function LinkRoute(): React.ReactElement {
}
}, [isDeleteConfirmShown, isCommandPaletteOpen, isInCreateMode, handleCommandPaletteClose])
useKey("Escape", () => {
setDisableEnterKey(false)
setNuqsEditId(null)
})
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<>
<LinkHeader />
<LinkManage />
<LinkList
@@ -61,6 +67,6 @@ export function LinkRoute(): React.ReactElement {
disableEnterKey={disableEnterKey}
/>
<LinkBottomBar />
</div>
</>
)
}

View File

@@ -1,9 +1,11 @@
"use client"
import React, { useCallback, useEffect, useRef } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { icons } from "lucide-react"
import type { icons } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { getSpecialShortcut, formatShortcut, isMacOS } from "@/lib/utils"
import { cn, getShortcutKeys } from "@/lib/utils"
import { LaIcon } from "@/components/custom/la-icon"
import { useAtom } from "jotai"
import { parseAsBoolean, useQueryState } from "nuqs"
@@ -13,6 +15,7 @@ import { PersonalLink } from "@/lib/schema"
import { ID } from "jazz-tools"
import { globalLinkFormExceptionRefsAtom } from "./partials/form/link-form"
import { useLinkActions } from "./hooks/use-link-actions"
import { useKeydownListener } from "@/hooks/use-keydown-listener"
interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Button> {
icon: keyof typeof icons
@@ -21,9 +24,9 @@ interface ToolbarButtonProps extends React.ComponentPropsWithoutRef<typeof Butto
}
const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonProps>(
({ icon, onClick, tooltip, ...props }, ref) => {
({ icon, onClick, tooltip, className, ...props }, ref) => {
const button = (
<Button variant="ghost" className="h-8 min-w-14" onClick={onClick} ref={ref} {...props}>
<Button variant="ghost" className={cn("h-8 min-w-14 p-0", className)} onClick={onClick} ref={ref} {...props}>
<LaIcon name={icon} />
</Button>
)
@@ -116,28 +119,25 @@ export const LinkBottomBar: React.FC = () => {
}
}
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isCreateShortcut = isMacOS()
? event.ctrlKey && event.metaKey && event.key.toLowerCase() === "n"
: event.ctrlKey && event.key.toLowerCase() === "n" && (event.metaKey || event.altKey)
const handleKeydown = useCallback(
(event: KeyboardEvent) => {
const isCreateShortcut = event.key === "c"
if (isCreateShortcut) {
event.preventDefault()
handleCreateMode()
}
}
},
[handleCreateMode]
)
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleCreateMode])
useKeydownListener(handleKeydown)
const shortcutKeys = getSpecialShortcut("expandToolbar")
const shortcutText = formatShortcut(shortcutKeys)
const shortcutText = getShortcutKeys(["c"])
return (
<motion.div
className="bg-background absolute bottom-0 left-0 right-0 border-t"
className="bg-background absolute bottom-0 left-0 right-0 h-11 border-t"
animate={{ y: 0 }}
initial={{ y: "100%" }}
>
@@ -145,7 +145,7 @@ export const LinkBottomBar: React.FC = () => {
{editId && (
<motion.div
key="expanded"
className="flex items-center justify-center gap-1 px-2 py-1"
className="flex h-full items-center justify-center gap-1 px-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
@@ -165,7 +165,7 @@ export const LinkBottomBar: React.FC = () => {
{!editId && (
<motion.div
key="collapsed"
className="flex items-center justify-center gap-1 px-2 py-1"
className="flex h-full items-center justify-center gap-1 px-2"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
@@ -176,11 +176,10 @@ export const LinkBottomBar: React.FC = () => {
<ToolbarButton
icon={"Plus"}
onClick={handleCreateMode}
tooltip={`New Link (${shortcutText})`}
tooltip={`New Link (${shortcutText.map(s => s.symbol).join("")})`}
ref={plusBtnRef}
/>
)}
{/* <ToolbarButton icon={"Ellipsis"} ref={plusMoreBtnRef} /> */}
</motion.div>
)}
</AnimatePresence>

View File

@@ -26,7 +26,7 @@ export const LinkHeader = React.memo(() => {
return (
<>
<ContentHeader className="px-6 max-lg:px-4 lg:py-5">
<ContentHeader className="px-6 max-lg:px-4 lg:py-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">

View File

@@ -24,6 +24,9 @@ import { commandPaletteOpenAtom } from "@/components/custom/command-palette/comm
import { useConfirm } from "@omit/react-confirm-dialog"
import { useLinkActions } from "./hooks/use-link-actions"
import { isDeleteConfirmShownAtom } from "./LinkRoute"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { useKeyboardManager } from "@/hooks/use-keyboard-manager"
import { useKeydownListener } from "@/hooks/use-keydown-listener"
interface LinkListProps {
activeItemIndex: number | null
@@ -77,12 +80,6 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
})
)
useKey("Escape", () => {
if (editId) {
setEditId(null)
}
})
useKey(
event => (event.metaKey || event.ctrlKey) && event.key === "Backspace",
async () => {
@@ -136,59 +133,52 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
})
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (isCommandPalettePpen || !me?.root?.personalLinks || sortedLinks.length === 0 || editId !== null) return
const { isKeyboardDisabled } = useKeyboardManager("XComponent")
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex =
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
useKeydownListener((e: KeyboardEvent) => {
if (
isKeyboardDisabled ||
isCommandPalettePpen ||
!me?.root?.personalLinks ||
sortedLinks.length === 0 ||
editId !== null
)
return
if (e.metaKey && sort === "manual") {
const linksArray = [...me.root.personalLinks]
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex =
e.key === "ArrowUp" ? Math.max(0, prevIndex - 1) : Math.min(sortedLinks.length - 1, prevIndex + 1)
while (me.root.personalLinks.length > 0) {
me.root.personalLinks.pop()
}
if (e.metaKey && sort === "manual") {
const linksArray = [...me.root.personalLinks]
const newLinks = arrayMove(linksArray, prevIndex, newIndex)
newLinks.forEach(link => {
if (link) {
me.root.personalLinks.push(link)
}
})
updateSequences(me.root.personalLinks)
while (me.root.personalLinks.length > 0) {
me.root.personalLinks.pop()
}
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null) {
e.preventDefault()
const activeLink = sortedLinks[activeItemIndex]
if (activeLink) {
setEditId(activeLink.id)
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)
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [
me?.root?.personalLinks,
sortedLinks,
editId,
sort,
updateSequences,
isCommandPalettePpen,
activeItemIndex,
setEditId,
setActiveItemIndex,
disableEnterKey
])
})
const handleDragStart = useCallback(
(event: DragStartEvent) => {
@@ -245,9 +235,11 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
setDraggingId(null)
}
const setElementRef = useActiveItemScroll<HTMLLIElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div
className="mb-14 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
className="mb-11 flex w-full flex-1 flex-col overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={0}
>
<DndContext
@@ -271,6 +263,7 @@ const LinkList: React.FC<LinkListProps> = ({ activeItemIndex, setActiveItemIndex
isActive={activeItemIndex === index}
setActiveItemIndex={setActiveItemIndex}
index={index}
ref={el => setElementRef(el, index)}
/>
)
)}

View File

@@ -21,7 +21,7 @@ export const DescriptionInput: React.FC<DescriptionInputProps> = () => {
<TextareaAutosize
{...field}
autoComplete="off"
placeholder="Description (optional)"
placeholder="Description"
className="placeholder:text-muted-foreground/70 resize-none overflow-y-auto border-none p-1.5 text-[13px] font-medium shadow-none focus-visible:ring-0"
/>
</FormControl>

View File

@@ -231,7 +231,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
<TopicSelector
{...field}
renderSelectedText={() => (
<span className="truncate">{selectedTopic?.prettyName || "Select a topic"}</span>
<span className="truncate">{selectedTopic?.prettyName || "Topic"}</span>
)}
/>
</FormItem>

View File

@@ -24,7 +24,7 @@ export const NotesSection: React.FC = () => {
<Input
{...field}
autoComplete="off"
placeholder="Take a notes..."
placeholder="Notes"
className={cn("placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0")}
/>
</>

View File

@@ -15,7 +15,7 @@ import { cn, ensureUrlProtocol } from "@/lib/utils"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { linkOpenPopoverForIdAtom } from "@/store/link"
interface LinkItemProps {
interface LinkItemProps extends React.HTMLAttributes<HTMLLIElement> {
personalLink: PersonalLink
disabled?: boolean
isEditing: boolean
@@ -26,134 +26,138 @@ interface LinkItemProps {
index: number
}
export const LinkItem: React.FC<LinkItemProps> = ({
isEditing,
setEditId,
personalLink,
disabled = false,
isDragging,
isActive,
setActiveItemIndex,
index
}) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
export const LinkItem = React.forwardRef<HTMLLIElement, LinkItemProps>(
({ personalLink, disabled, isEditing, setEditId, isDragging, isActive, setActiveItemIndex, index }, ref) => {
const [openPopoverForId, setOpenPopoverForId] = useAtom(linkOpenPopoverForIdAtom)
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: personalLink.id, disabled })
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
}),
[transform, transition, isDragging]
)
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
pointerEvents: isDragging ? "none" : "auto"
}),
[transform, transition, isDragging]
)
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
const handleOnClose = useCallback(() => setEditId(null), [setEditId])
const handleRowDoubleClick = useCallback(() => setEditId(personalLink.id), [setEditId, personalLink.id])
const handleSuccess = useCallback(() => setEditId(null), [setEditId])
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),
[personalLink.learningState]
)
const selectedLearningState = useMemo(
() => LEARNING_STATES.find(ls => ls.value === personalLink.learningState),
[personalLink.learningState]
)
const handleLearningStateSelect = useCallback(
(value: string) => {
const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
setOpenPopoverForId(null)
},
[personalLink, setOpenPopoverForId]
)
const handleLearningStateSelect = useCallback(
(value: string) => {
const learningState = value as LearningStateValue
personalLink.learningState = personalLink.learningState === learningState ? undefined : learningState
setOpenPopoverForId(null)
},
[personalLink, setOpenPopoverForId]
)
if (isEditing) {
return <LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
}
if (isEditing) {
return (
<LinkForm onClose={handleOnClose} personalLink={personalLink} onSuccess={handleSuccess} onFail={() => {}} />
)
}
return (
<li
ref={setNodeRef}
style={style as React.CSSProperties}
{...attributes}
{...listeners}
tabIndex={0}
onFocus={() => setActiveItemIndex(index)}
onBlur={() => setActiveItemIndex(null)}
className={cn(
"relative cursor-default outline-none",
"mx-auto grid w-[98%] grid-cols-[auto_1fr_auto] items-center gap-x-2 rounded-lg p-2",
{
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
}
)}
onDoubleClick={handleRowDoubleClick}
>
<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">
{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}
/>
return (
<li
ref={node => {
setNodeRef(node)
if (typeof ref === "function") {
ref(node)
} else if (ref) {
ref.current = node
}
}}
style={style as React.CSSProperties}
{...attributes}
{...listeners}
tabIndex={0}
onFocus={() => setActiveItemIndex(index)}
onBlur={() => setActiveItemIndex(null)}
className={cn(
"relative cursor-default outline-none",
"grid grid-cols-[auto_1fr_auto] items-center gap-x-2 py-2 max-lg:px-4 sm:px-5 sm:py-2",
{
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
}
)}
onDoubleClick={handleRowDoubleClick}
>
<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">
{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">
<p className="text-primary hover:text-primary truncate text-sm font-medium">{personalLink.title}</p>
{personalLink.url && (
<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" />
<Link
href={ensureUrlProtocol(personalLink.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="hover:text-primary truncate text-xs"
>
{personalLink.url}
</Link>
</div>
{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>
{personalLink.url && (
<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" />
<Link
href={ensureUrlProtocol(personalLink.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="hover:text-primary truncate text-xs"
>
{personalLink.url}
</Link>
</div>
)}
</div>
</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>
</li>
)
}
)
<div className="flex shrink-0 items-center justify-end">
{personalLink.topic && (
<Badge variant="secondary" className="border-muted-foreground/25">
{personalLink.topic.prettyName}
</Badge>
)}
</div>
</li>
)
}
LinkItem.displayName = "LinkItem"

View File

@@ -23,11 +23,11 @@ import { usePageActions } from "../hooks/use-page-actions"
const TITLE_PLACEHOLDER = "Untitled"
const emptyPage = (page: PersonalPage): boolean => {
const isPageEmpty = (page: PersonalPage): boolean => {
return (!page.title || page.title.trim() === "") && (!page.content || Object.keys(page.content).length === 0)
}
export const DeleteEmptyPage = (currentPageId: string | null) => {
const useDeleteEmptyPage = (currentPageId: string | null) => {
const router = useRouter()
const { me } = useAccount({
root: {
@@ -36,21 +36,17 @@ export const DeleteEmptyPage = (currentPageId: string | null) => {
})
useEffect(() => {
const handleRouteChange = () => {
return () => {
if (!currentPageId || !me?.root?.personalPages) return
const currentPage = me.root.personalPages.find(page => page?.id === currentPageId)
if (currentPage && emptyPage(currentPage)) {
if (currentPage && isPageEmpty(currentPage)) {
const index = me.root.personalPages.findIndex(page => page?.id === currentPageId)
if (index !== -1) {
me.root.personalPages.splice(index, 1)
}
}
}
return () => {
handleRouteChange()
}
}, [currentPageId, me, router])
}
@@ -62,9 +58,9 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
const { deletePage } = usePageActions()
const confirm = useConfirm()
DeleteEmptyPage(pageId)
// useDeleteEmptyPage(pageId)
const handleDelete = async () => {
const handleDelete = useCallback(async () => {
const result = await confirm({
title: "Delete page",
description: "Are you sure you want to delete this page?",
@@ -78,7 +74,7 @@ export function PageDetailRoute({ pageId }: { pageId: string }) {
deletePage(me, pageId as ID<PersonalPage>)
router.push("/pages")
}
}
}, [confirm, deletePage, me, pageId, router])
if (!page) return null
@@ -130,30 +126,34 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const contentEditorRef = useRef<LAEditorRef>(null)
const isTitleInitialMount = useRef(true)
const isContentInitialMount = useRef(true)
const isInitialFocusApplied = useRef(false)
const updatePageContent = (content: Content, model: PersonalPage) => {
const updatePageContent = useCallback((content: Content, model: PersonalPage) => {
if (isContentInitialMount.current) {
isContentInitialMount.current = false
return
}
model.content = content
model.updatedAt = new Date()
}
}, [])
const handleUpdateTitle = (editor: Editor) => {
if (isTitleInitialMount.current) {
isTitleInitialMount.current = false
return
}
const handleUpdateTitle = useCallback(
(editor: Editor) => {
if (isTitleInitialMount.current) {
isTitleInitialMount.current = false
return
}
const newTitle = editor.getText()
if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "")
page.title = newTitle
page.slug = slug
page.updatedAt = new Date()
}
}
const newTitle = editor.getText()
if (newTitle !== page.title) {
const slug = generateUniqueSlug(page.title?.toString() || "")
page.title = newTitle
page.slug = slug
page.updatedAt = new Date()
}
},
[page]
)
const handleTitleKeyDown = useCallback((view: EditorView, event: KeyboardEvent) => {
const editor = titleEditorRef.current
@@ -201,7 +201,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
const titleEditor = useEditor({
immediatelyRender: false,
autofocus: false,
extensions: [
FocusClasses,
Paragraph,
@@ -246,12 +245,15 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
isTitleInitialMount.current = true
isContentInitialMount.current = true
if (!page.title) {
titleEditor?.commands.focus()
} else {
contentEditorRef.current?.editor?.commands.focus()
if (!isInitialFocusApplied.current && titleEditor && contentEditorRef.current?.editor) {
isInitialFocusApplied.current = true
if (!page.title) {
titleEditor?.commands.focus()
} else {
contentEditorRef.current.editor.commands.focus()
}
}
}, [page.title, titleEditor, contentEditorRef])
}, [page.title, titleEditor])
return (
<div className="relative flex grow flex-col overflow-y-auto [scrollbar-gutter:stable]">

View File

@@ -1,54 +1,58 @@
"use client"
import * as React from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { LaIcon } from "@/components/custom/la-icon"
import { useAccount } from "@/lib/providers/jazz-provider"
import { useRouter } from "next/navigation"
import { PersonalPage } from "@/lib/schema"
import { toast } from "sonner"
import { usePageActions } from "./hooks/use-page-actions"
export const PageHeader = React.memo(() => {
interface PageHeaderProps {}
export const PageHeader: React.FC<PageHeaderProps> = React.memo(() => {
const { me } = useAccount()
const router = useRouter()
const { newPage } = usePageActions()
if (!me) return null
const handleClick = () => {
try {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
router.push(`/pages/${newPersonalPage.id}`)
} catch (error) {
toast.error("Failed to create page")
}
const handleNewPageClick = () => {
const page = newPage(me)
router.push(`/pages/${page.id}`)
}
return (
<ContentHeader className="px-6 py-5 max-lg:px-4">
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left font-bold lg:text-xl">Pages</span>
</div>
</div>
<div className="flex flex-auto"></div>
<div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2">
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={handleClick}>
<LaIcon name="Plus" />
<span className="hidden md:block">New page</span>
</Button>
</div>
</div>
<ContentHeader className="px-6 py-4 max-lg:px-4">
<HeaderTitle />
<div className="flex flex-auto" />
<NewPageButton onClick={handleNewPageClick} />
</ContentHeader>
)
})
PageHeader.displayName = "PageHeader"
const HeaderTitle: React.FC = () => (
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left font-bold lg:text-xl">Pages</span>
</div>
</div>
)
interface NewPageButtonProps {
onClick: () => void
}
const NewPageButton: React.FC<NewPageButtonProps> = ({ onClick }) => (
<div className="flex w-auto items-center justify-end">
<div className="flex items-center gap-2">
<Button size="sm" type="button" variant="secondary" className="gap-x-2" onClick={onClick}>
<LaIcon name="Plus" />
<span className="hidden md:block">New page</span>
</Button>
</div>
</div>
)

View File

@@ -1,69 +0,0 @@
import React, { useEffect, useRef, useCallback } from "react"
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
interface UseKeyboardNavigationProps {
personalPages?: PersonalPageLists | null
activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
isCommandPaletteOpen: boolean
disableEnterKey: boolean
onEnter?: (selectedPage: PersonalPage) => void
}
export const useKeyboardNavigation = ({
personalPages,
activeItemIndex,
setActiveItemIndex,
isCommandPaletteOpen,
disableEnterKey,
onEnter
}: UseKeyboardNavigationProps) => {
const listRef = useRef<HTMLDivElement>(null)
const itemRefs = useRef<(HTMLAnchorElement | null)[]>([])
const itemCount = personalPages?.length || 0
const scrollIntoView = useCallback((index: number) => {
if (itemRefs.current[index]) {
itemRefs.current[index]?.scrollIntoView({
block: "nearest"
})
}
}, [])
useEffect(() => {
if (activeItemIndex !== null) {
scrollIntoView(activeItemIndex)
}
}, [activeItemIndex, scrollIntoView])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isCommandPaletteOpen) return
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) {
e.preventDefault()
const selectedPage = personalPages[activeItemIndex]
if (selectedPage) onEnter?.(selectedPage)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, onEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
const setItemRef = useCallback((el: HTMLAnchorElement | null, index: number) => {
itemRefs.current[index] = el
}, [])
return { listRef, setItemRef }
}

View File

@@ -4,6 +4,15 @@ import { LaAccount, PersonalPage } from "@/lib/schema"
import { ID } from "jazz-tools"
export const usePageActions = () => {
const newPage = useCallback((me: LaAccount): PersonalPage => {
const newPersonalPage = PersonalPage.create(
{ public: false, createdAt: new Date(), updatedAt: new Date() },
{ owner: me._owner }
)
me.root?.personalPages?.push(newPersonalPage)
return newPersonalPage
}, [])
const deletePage = useCallback((me: LaAccount, pageId: ID<PersonalPage>): void => {
if (!me.root?.personalPages) return
@@ -32,5 +41,5 @@ export const usePageActions = () => {
}
}, [])
return { deletePage }
return { newPage, deletePage }
}

View File

@@ -1,15 +1,15 @@
import React, { useMemo, useCallback } from "react"
import React, { useMemo, useCallback, useEffect } from "react"
import { Primitive } from "@radix-ui/react-primitive"
import { useAccount } from "@/lib/providers/jazz-provider"
import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { PageItem } from "./partials/page-item"
import { useKeyboardNavigation } from "./hooks/use-keyboard-navigation"
import { useMedia } from "react-use"
import { Column } from "./partials/column"
import { useColumnStyles } from "./hooks/use-column-styles"
import { PersonalPage, PersonalPageLists } from "@/lib/schema"
import { useRouter } from "next/navigation"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { Column } from "@/components/custom/column"
interface PageListProps {
activeItemIndex: number | null
@@ -23,6 +23,7 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
const { me } = useAccount({ root: { personalPages: [] } })
const personalPages = useMemo(() => me?.root?.personalPages, [me?.root?.personalPages])
const router = useRouter()
const itemCount = personalPages?.length || 0
const handleEnter = useCallback(
(selectedPage: PersonalPage) => {
@@ -31,24 +32,35 @@ export const PageList: React.FC<PageListProps> = ({ activeItemIndex, setActiveIt
[router]
)
const { listRef, setItemRef } = useKeyboardNavigation({
personalPages,
activeItemIndex,
setActiveItemIndex,
isCommandPaletteOpen,
disableEnterKey,
onEnter: handleEnter
})
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isCommandPaletteOpen) return
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalPages) {
e.preventDefault()
const selectedPage = personalPages[activeItemIndex]
if (selectedPage) handleEnter?.(selectedPage)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalPages, handleEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
return (
<div className="flex h-full w-full flex-col">
<div className="flex h-full w-full flex-col overflow-hidden border-t">
{!isTablet && <ColumnHeader />}
<PageListItems
listRef={listRef}
setItemRef={setItemRef}
personalPages={personalPages}
activeItemIndex={activeItemIndex}
/>
<PageListItems personalPages={personalPages} activeItemIndex={activeItemIndex} />
</div>
)
}
@@ -72,29 +84,30 @@ export const ColumnHeader: React.FC = () => {
}
interface PageListItemsProps {
listRef: React.RefObject<HTMLDivElement>
setItemRef: (el: HTMLAnchorElement | null, index: number) => void
personalPages?: PersonalPageLists | null
activeItemIndex: number | null
}
const PageListItems: React.FC<PageListItemsProps> = ({ listRef, setItemRef, personalPages, activeItemIndex }) => (
<Primitive.div
ref={listRef}
className="divide-primary/5 mx-auto my-2 flex w-[99%] flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={-1}
role="list"
>
{personalPages?.map(
(page, index) =>
page?.id && (
<PageItem
key={page.id}
ref={(el: HTMLAnchorElement | null) => setItemRef(el, index)}
page={page}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
const PageListItems: React.FC<PageListItemsProps> = ({ personalPages, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLAnchorElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={-1}
role="list"
>
{personalPages?.map(
(page, index) =>
page?.id && (
<PageItem
key={page.id}
ref={el => setElementRef(el, index)}
page={page}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
}

View File

@@ -3,10 +3,10 @@ import Link from "next/link"
import { cn } from "@/lib/utils"
import { PersonalPage } from "@/lib/schema"
import { Badge } from "@/components/ui/badge"
import { Column } from "./column"
import { useMedia } from "react-use"
import { useColumnStyles } from "../hooks/use-column-styles"
import { format } from "date-fns"
import { Column } from "@/components/custom/column"
interface PageItemProps {
page: PersonalPage
@@ -21,14 +21,10 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
<Link
ref={ref}
tabIndex={isActive ? 0 : -1}
className={cn(
"relative block cursor-default rounded-lg outline-none",
"h-12 items-center gap-x-2 py-2 max-lg:px-4 sm:px-6",
{
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
}
)}
className={cn("relative block cursor-default outline-none", "min-h-12 py-2 max-lg:px-4 sm:px-6", {
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
})}
href={`/pages/${page.id}`}
role="listitem"
>
@@ -38,14 +34,9 @@ export const PageItem = React.forwardRef<HTMLAnchorElement, PageItemProps>(({ pa
</Column.Wrapper>
{!isTablet && (
<>
{/* <Column.Wrapper style={columnStyles.content}>
<Column.Text className="text-[13px]">{page.slug}</Column.Text>
</Column.Wrapper> */}
<Column.Wrapper style={columnStyles.topic}>
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
</Column.Wrapper>
</>
<Column.Wrapper style={columnStyles.topic}>
{page.topic && <Badge variant="secondary">{page.topic.prettyName}</Badge>}
</Column.Wrapper>
)}
<Column.Wrapper style={columnStyles.updated} className="flex justify-end">

View File

@@ -0,0 +1,35 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { TopicHeader } from "./header"
import { TopicList } from "./list"
import { useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
export function TopicRoute() {
const [activeItemIndex, setActiveItemIndex] = useState<number | null>(null)
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const [disableEnterKey, setDisableEnterKey] = useState(false)
const handleCommandPaletteClose = useCallback(() => {
setDisableEnterKey(true)
setTimeout(() => setDisableEnterKey(false), 100)
}, [])
useEffect(() => {
if (!isCommandPaletteOpen) {
handleCommandPaletteClose()
}
}, [isCommandPaletteOpen, handleCommandPaletteClose])
return (
<div className="flex h-full flex-auto flex-col overflow-hidden">
<TopicHeader />
<TopicList
activeItemIndex={activeItemIndex}
setActiveItemIndex={setActiveItemIndex}
disableEnterKey={disableEnterKey}
/>
</div>
)
}

View File

@@ -1,12 +1,17 @@
"use client"
import React, { useMemo, useRef } from "react"
import React, { useMemo, useState } from "react"
import { TopicDetailHeader } from "./Header"
import { TopicSections } from "./partials/topic-sections"
import { useAccountOrGuest, useCoState } from "@/lib/providers/jazz-provider"
import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants"
import { Topic } from "@/lib/schema"
import { TopicDetailList } from "./list"
import { atom } from "jotai"
import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { useTopicData } from "@/hooks/use-topic-data"
import { Skeleton } from "@/components/ui/skeleton"
import { GraphNode } from "../../public/PublicHomeRoute"
import { LaIcon } from "@/components/custom/la-icon"
const graph_data_promise = import("@/components/routes/public/graph-data.json").then(a => a.default)
interface TopicDetailRouteProps {
topicName: string
}
@@ -14,27 +19,71 @@ interface TopicDetailRouteProps {
export const openPopoverForIdAtom = atom<string | null>(null)
export function TopicDetailRoute({ topicName }: TopicDetailRouteProps) {
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
const { topic } = useTopicData(topicName, me)
// const { activeIndex, setActiveIndex, containerRef, linkRefs } = useLinkNavigation(allLinks)
const linksRefDummy = useRef<(HTMLLIElement | null)[]>([])
const containerRefDummy = useRef<HTMLDivElement>(null)
const raw_graph_data = React.use(graph_data_promise) as GraphNode[]
if (!topic || !me) {
return null
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
const topicID = useMemo(() => me && Topic.findUnique({ topicName }, JAZZ_GLOBAL_GROUP_ID, me), [topicName, me])
const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [] } })
const [activeIndex, setActiveIndex] = useState(-1)
const topicExists = raw_graph_data.find(node => node.name === topicName)
if (!topicExists) {
return <NotFoundPlaceholder />
}
const flattenedItems = topic?.latestGlobalGuide?.sections.flatMap(section => [
{ type: "section" as const, data: section },
...(section?.links?.map(link => ({ type: "link" as const, data: link })) || [])
])
if (!topic || !me || !flattenedItems) {
return <TopicDetailSkeleton />
}
return (
<div className="flex h-full flex-auto flex-col">
<>
<TopicDetailHeader topic={topic} />
<TopicSections
topic={topic}
sections={topic.latestGlobalGuide?.sections}
activeIndex={0}
setActiveIndex={() => {}}
linkRefs={linksRefDummy}
containerRef={containerRefDummy}
/>
<TopicDetailList items={flattenedItems} topic={topic} activeIndex={activeIndex} setActiveIndex={setActiveIndex} />
</>
)
}
function NotFoundPlaceholder() {
return (
<div className="flex h-full grow flex-col items-center justify-center gap-3">
<div className="flex flex-row items-center gap-1.5">
<LaIcon name="CircleAlert" />
<span className="text-left font-medium">Topic not found</span>
</div>
<span className="max-w-sm text-left text-sm">There is no topic with the given identifier.</span>
</div>
)
}
function TopicDetailSkeleton() {
return (
<>
<div className="flex items-center justify-between px-6 py-5 max-lg:px-4">
<div className="flex items-center space-x-4">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-6 w-48" />
</div>
<Skeleton className="h-9 w-36" />
</div>
<div className="space-y-4 p-6 max-lg:px-4">
{[...Array(10)].map((_, index) => (
<div key={index} className="flex items-center space-x-4">
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</>
)
}

View File

@@ -0,0 +1,93 @@
import React, { useRef, useCallback } from "react"
import { useVirtualizer, VirtualItem } from "@tanstack/react-virtual"
import { Link as LinkSchema, Section as SectionSchema, Topic } from "@/lib/schema"
import { LinkItem } from "./partials/link-item"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
export type FlattenedItem = { type: "link"; data: LinkSchema | null } | { type: "section"; data: SectionSchema | null }
interface TopicDetailListProps {
items: FlattenedItem[]
topic: Topic
activeIndex: number
setActiveIndex: (index: number) => void
}
export function TopicDetailList({ items, topic, activeIndex, setActiveIndex }: TopicDetailListProps) {
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
const personalLinks = !me || me._type === "Anonymous" ? undefined : me.root.personalLinks
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 5
})
const renderItem = useCallback(
(virtualRow: VirtualItem) => {
const item = items[virtualRow.index]
if (item.type === "section") {
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className="flex flex-col"
>
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
<p className="text-foreground text-sm font-medium">{item.data?.title}</p>
<div className="flex-1 border-b" />
</div>
</div>
)
}
if (item.data?.id) {
return (
<LinkItem
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
topic={topic}
link={item.data as LinkSchema}
isActive={activeIndex === virtualRow.index}
index={virtualRow.index}
setActiveIndex={setActiveIndex}
personalLinks={personalLinks}
/>
)
}
return null
},
[items, topic, activeIndex, setActiveIndex, virtualizer, personalLinks]
)
return (
<div ref={parentRef} className="flex-1 overflow-auto">
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative"
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`
}}
>
{virtualizer.getVirtualItems().map(renderItem)}
</div>
</div>
</div>
)
}

View File

@@ -10,198 +10,199 @@ import { Button } from "@/components/ui/button"
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
import { cn, ensureUrlProtocol, generateUniqueSlug } from "@/lib/utils"
import { Link as LinkSchema, PersonalLink, Topic } from "@/lib/schema"
import { Link as LinkSchema, PersonalLink, PersonalLinkLists, Topic } from "@/lib/schema"
import { openPopoverForIdAtom } from "../TopicDetailRoute"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { useAccountOrGuest } from "@/lib/providers/jazz-provider"
import { useClerk } from "@clerk/nextjs"
interface LinkItemProps {
interface LinkItemProps extends React.ComponentPropsWithoutRef<"div"> {
topic: Topic
link: LinkSchema
isActive: boolean
index: number
setActiveIndex: (index: number) => void
personalLinks?: PersonalLinkLists
}
export const LinkItem = React.memo(
React.forwardRef<HTMLLIElement, LinkItemProps>(({ topic, link, isActive, index, setActiveIndex }, ref) => {
const clerk = useClerk()
const pathname = usePathname()
const router = useRouter()
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
React.forwardRef<HTMLDivElement, LinkItemProps>(
({ topic, link, isActive, index, setActiveIndex, className, personalLinks, ...props }, ref) => {
const clerk = useClerk()
const pathname = usePathname()
const router = useRouter()
const [, setOpenPopoverForId] = useAtom(openPopoverForIdAtom)
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const { me } = useAccountOrGuest()
const { me } = useAccountOrGuest({ root: { personalLinks: [] } })
const personalLink = useMemo(() => {
return personalLinks?.find(pl => pl?.link?.id === link.id)
}, [personalLinks, link.id])
const personalLinks = useMemo(() => {
if (!me || me._type === "Anonymous") return undefined
return me?.root?.personalLinks || []
}, [me])
const selectedLearningState = useMemo(() => {
return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
}, [personalLink?.learningState])
const personalLink = useMemo(() => {
return personalLinks?.find(pl => pl?.link?.id === link.id)
}, [personalLinks, link.id])
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setActiveIndex(index)
},
[index, setActiveIndex]
)
const selectedLearningState = useMemo(() => {
return LEARNING_STATES.find(ls => ls.value === personalLink?.learningState)
}, [personalLink?.learningState])
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setActiveIndex(index)
},
[index, setActiveIndex]
)
const handleSelectLearningState = useCallback(
(learningState: LearningStateValue) => {
if (!personalLinks || !me || me?._type === "Anonymous") {
return clerk.redirectToSignIn({
redirectUrl: pathname
})
}
const defaultToast = {
duration: 5000,
position: "bottom-right" as const,
closeButton: true,
action: {
label: "Go to list",
onClick: () => router.push("/links")
const handleSelectLearningState = useCallback(
(learningState: LearningStateValue) => {
if (!personalLinks || !me || me?._type === "Anonymous") {
return clerk.redirectToSignIn({
redirectUrl: pathname
})
}
}
if (personalLink) {
if (personalLink.learningState === learningState) {
personalLink.learningState = undefined
toast.error("Link learning state removed", defaultToast)
const defaultToast = {
duration: 5000,
position: "bottom-right" as const,
closeButton: true,
action: {
label: "Go to list",
onClick: () => router.push("/links")
}
}
if (personalLink) {
if (personalLink.learningState === learningState) {
personalLink.learningState = undefined
toast.error("Link learning state removed", defaultToast)
} else {
personalLink.learningState = learningState
toast.success("Link learning state updated", defaultToast)
}
} else {
personalLink.learningState = learningState
toast.success("Link learning state updated", defaultToast)
const slug = generateUniqueSlug(link.title)
const newPersonalLink = PersonalLink.create(
{
url: link.url,
title: link.title,
slug,
link,
learningState,
sequence: personalLinks.length + 1,
completed: false,
topic,
createdAt: new Date(),
updatedAt: new Date()
},
{ owner: me }
)
personalLinks.push(newPersonalLink)
toast.success("Link added.", {
...defaultToast,
description: `${link.title} has been added to your personal link.`
})
}
} else {
const slug = generateUniqueSlug(link.title)
const newPersonalLink = PersonalLink.create(
setOpenPopoverForId(null)
setIsPopoverOpen(false)
},
[personalLink, personalLinks, me, link, router, topic, setOpenPopoverForId, clerk, pathname]
)
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
setIsPopoverOpen(open)
setOpenPopoverForId(open ? link.id : null)
},
[link.id, setOpenPopoverForId]
)
return (
<div
ref={ref}
tabIndex={0}
onClick={handleClick}
className={cn(
"relative flex h-14 cursor-pointer items-center outline-none xl:h-11",
{
url: link.url,
title: link.title,
slug,
link,
learningState,
sequence: personalLinks.length + 1,
completed: false,
topic,
createdAt: new Date(),
updatedAt: new Date()
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
},
{ owner: me }
)
personalLinks.push(newPersonalLink)
toast.success("Link added.", {
...defaultToast,
description: `${link.title} has been added to your personal link.`
})
}
setOpenPopoverForId(null)
setIsPopoverOpen(false)
},
[personalLink, personalLinks, me, link, router, setOpenPopoverForId, topic, clerk, pathname]
)
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
setIsPopoverOpen(open)
setOpenPopoverForId(open ? link.id : null)
},
[link.id, setOpenPopoverForId]
)
return (
<li
ref={ref}
tabIndex={0}
onClick={handleClick}
className={cn("relative flex h-14 cursor-pointer items-center outline-none xl:h-11", {
"bg-muted-foreground/10": isActive,
"hover:bg-muted/50": !isActive
})}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
onClick={e => e.stopPropagation()}
>
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={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={(value: string) => handleSelectLearningState(value as LearningStateValue)}
/>
</PopoverContent>
</Popover>
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p
className={cn(
"text-primary hover:text-primary line-clamp-1 text-sm font-medium xl:truncate",
isActive && "font-bold"
)}
>
{link.title}
</p>
<div className="group flex items-center gap-x-1">
<LaIcon
name="Link"
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary flex-none"
/>
<Link
href={ensureUrlProtocol(link.url)}
passHref
prefetch={false}
target="_blank"
className
)}
{...props}
>
<div className="flex grow justify-between gap-x-6 px-6 max-lg:px-4">
<div className="flex min-w-0 items-center gap-x-4">
<Popover open={isPopoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className={cn("size-7 shrink-0 p-0", "hover:bg-accent-foreground/10")}
onClick={e => e.stopPropagation()}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="xl:truncate">{link.url}</span>
</Link>
{selectedLearningState?.icon ? (
<LaIcon name={selectedLearningState.icon} className={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={(value: string) => handleSelectLearningState(value as LearningStateValue)}
/>
</PopoverContent>
</Popover>
<div className="w-full min-w-0 flex-auto">
<div className="gap-x-2 space-y-0.5 xl:flex xl:flex-row">
<p
className={cn(
"text-primary hover:text-primary line-clamp-1 text-sm font-medium",
isActive && "font-bold"
)}
>
{link.title}
</p>
<div className="group flex items-center gap-x-1">
<LaIcon
name="Link"
aria-hidden="true"
className="text-muted-foreground group-hover:text-primary size-3.5 flex-none"
/>
<Link
href={ensureUrlProtocol(link.url)}
passHref
prefetch={false}
target="_blank"
onClick={e => e.stopPropagation()}
className="text-muted-foreground hover:text-primary text-xs"
>
<span className="line-clamp-1">{link.url}</span>
</Link>
</div>
</div>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-x-4"></div>
</div>
</li>
)
})
)
}
)
)
LinkItem.displayName = "LinkItem"

View File

@@ -1,94 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { LinkItem } from "./link-item"
import { LaAccount, PersonalLinkLists, Section as SectionSchema, Topic, UserRoot } from "@/lib/schema"
import { Skeleton } from "@/components/ui/skeleton"
import { LaIcon } from "@/components/custom/la-icon"
interface SectionProps {
topic: Topic
section: SectionSchema
activeIndex: number
startIndex: number
linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
setActiveIndex: (index: number) => void
}
export function Section({ topic, section, activeIndex, setActiveIndex, startIndex, linkRefs }: SectionProps) {
const [nLinksToLoad, setNLinksToLoad] = useState(10)
const linksToLoad = useMemo(() => {
return section.links?.slice(0, nLinksToLoad)
}, [section.links, nLinksToLoad])
return (
<div className="flex flex-col">
<div className="flex items-center gap-4 px-6 py-2 max-lg:px-4">
<p className="text-foreground text-sm font-medium">{section.title}</p>
<div className="flex-1 border-b"></div>
</div>
<div className="flex flex-col gap-px py-2">
{linksToLoad?.map((link, index) =>
link?.url ? (
<LinkItem
key={index}
topic={topic}
link={link}
isActive={activeIndex === startIndex + index}
index={startIndex + index}
setActiveIndex={setActiveIndex}
ref={el => {
linkRefs.current[startIndex + index] = el
}}
/>
) : (
<Skeleton key={index} className="h-14 w-full xl:h-11" />
)
)}
{section.links?.length && section.links?.length > nLinksToLoad && (
<LoadMoreSpinner onLoadMore={() => setNLinksToLoad(n => n + 10)} />
)}
</div>
</div>
)
}
const LoadMoreSpinner = ({ onLoadMore }: { onLoadMore: () => void }) => {
const spinnerRef = useRef<HTMLDivElement>(null)
const handleIntersection = useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries
if (entry.isIntersecting) {
onLoadMore()
}
},
[onLoadMore]
)
useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, {
root: null,
rootMargin: "0px",
threshold: 1.0
})
const currentSpinnerRef = spinnerRef.current
if (currentSpinnerRef) {
observer.observe(currentSpinnerRef)
}
return () => {
if (currentSpinnerRef) {
observer.unobserve(currentSpinnerRef)
}
}
}, [handleIntersection])
return (
<div ref={spinnerRef} className="flex justify-center py-4">
<LaIcon name="Loader" className="size-6 animate-spin" />
</div>
)
}

View File

@@ -1,44 +0,0 @@
import React from "react"
import { Section } from "./section"
import { LaAccount, ListOfSections, PersonalLinkLists, Topic, UserRoot } from "@/lib/schema"
interface TopicSectionsProps {
topic: Topic
sections: (ListOfSections | null) | undefined
activeIndex: number
setActiveIndex: (index: number) => void
linkRefs: React.MutableRefObject<(HTMLLIElement | null)[]>
containerRef: React.RefObject<HTMLDivElement>
}
export function TopicSections({
topic,
sections,
activeIndex,
setActiveIndex,
linkRefs,
containerRef,
}: TopicSectionsProps) {
return (
<div ref={containerRef} className="flex w-full flex-1 flex-col overflow-y-auto [scrollbar-gutter:stable]">
<div tabIndex={-1} className="outline-none">
<div className="flex flex-1 flex-col gap-4" role="listbox" aria-label="Topic sections">
{sections?.map(
(section, sectionIndex) =>
section?.id && (
<Section
key={sectionIndex}
topic={topic}
section={section}
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
startIndex={sections.slice(0, sectionIndex).reduce((acc, s) => acc + (s?.links?.length || 0), 0)}
linkRefs={linkRefs}
/>
)
)}
</div>
</div>
</div>
)
}

View File

@@ -1,60 +0,0 @@
import { useState, useRef, useCallback, useEffect } from "react"
import { Link as LinkSchema } from "@/lib/schema"
import { ensureUrlProtocol } from "@/lib/utils"
export function useLinkNavigation(allLinks: (LinkSchema | null)[]) {
const [activeIndex, setActiveIndex] = useState(-1)
const containerRef = useRef<HTMLDivElement>(null)
const linkRefs = useRef<(HTMLLIElement | null)[]>(allLinks.map(() => null))
const scrollToLink = useCallback((index: number) => {
if (linkRefs.current[index] && containerRef.current) {
const linkElement = linkRefs.current[index]
const container = containerRef.current
const linkRect = linkElement?.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
if (linkRect && containerRect) {
if (linkRect.bottom > containerRect.bottom) {
container.scrollTop += linkRect.bottom - containerRect.bottom
} else if (linkRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - linkRect.top
}
}
}
}, [])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex(prevIndex => {
const newIndex = (prevIndex + 1) % allLinks.length
scrollToLink(newIndex)
return newIndex
})
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex(prevIndex => {
const newIndex = (prevIndex - 1 + allLinks.length) % allLinks.length
scrollToLink(newIndex)
return newIndex
})
} else if (e.key === "Enter" && activeIndex !== -1) {
const link = allLinks[activeIndex]
if (link) {
window.open(ensureUrlProtocol(link.url), "_blank")
}
}
},
[activeIndex, allLinks, scrollToLink]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
return { activeIndex, setActiveIndex, containerRef, linkRefs }
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { ContentHeader, SidebarToggleButton } from "@/components/custom/content-header"
import { useAccount } from "@/lib/providers/jazz-provider"
interface TopicHeaderProps {}
export const TopicHeader: React.FC<TopicHeaderProps> = React.memo(() => {
const { me } = useAccount()
if (!me) return null
return (
<ContentHeader className="px-6 py-4 max-lg:px-4">
<HeaderTitle />
<div className="flex flex-auto" />
</ContentHeader>
)
})
TopicHeader.displayName = "TopicHeader"
const HeaderTitle: React.FC = () => (
<div className="flex min-w-0 shrink-0 items-center gap-1.5">
<SidebarToggleButton />
<div className="flex min-h-0 items-center">
<span className="truncate text-left font-bold lg:text-xl">Topics</span>
</div>
</div>
)

View File

@@ -0,0 +1,14 @@
import { useMedia } from "react-use"
export const useColumnStyles = () => {
const isTablet = useMedia("(max-width: 640px)")
return {
title: {
"--width": "69px",
"--min-width": "200px",
"--max-width": isTablet ? "none" : "auto"
},
topic: { "--width": "65px", "--min-width": "120px", "--max-width": "120px" }
}
}

View File

@@ -0,0 +1,157 @@
import React, { useCallback, useEffect, useMemo } from "react"
import { Primitive } from "@radix-ui/react-primitive"
import { useAccount } from "@/lib/providers/jazz-provider"
import { atom, useAtom } from "jotai"
import { commandPaletteOpenAtom } from "@/components/custom/command-palette/command-palette"
import { TopicItem } from "./partials/topic-item"
import { useMedia } from "react-use"
import { useRouter } from "next/navigation"
import { useActiveItemScroll } from "@/hooks/use-active-item-scroll"
import { Column } from "@/components/custom/column"
import { useColumnStyles } from "./hooks/use-column-styles"
import { LaAccount, ListOfTopics, Topic, UserRoot } from "@/lib/schema"
import { LearningStateValue } from "@/lib/constants"
interface TopicListProps {
activeItemIndex: number | null
setActiveItemIndex: React.Dispatch<React.SetStateAction<number | null>>
disableEnterKey: boolean
}
interface MainTopicListProps extends TopicListProps {
me: {
root: {
topicsWantToLearn: ListOfTopics
topicsLearning: ListOfTopics
topicsLearned: ListOfTopics
} & UserRoot
} & LaAccount
}
export interface PersonalTopic {
topic: Topic | null
learningState: LearningStateValue
}
export const topicOpenPopoverForIdAtom = atom<string | null>(null)
export const TopicList: React.FC<TopicListProps> = ({ activeItemIndex, setActiveItemIndex, disableEnterKey }) => {
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
if (!me) return null
return (
<MainTopicList
me={me}
activeItemIndex={activeItemIndex}
setActiveItemIndex={setActiveItemIndex}
disableEnterKey={disableEnterKey}
/>
)
}
export const MainTopicList: React.FC<MainTopicListProps> = ({
me,
activeItemIndex,
setActiveItemIndex,
disableEnterKey
}) => {
const isTablet = useMedia("(max-width: 640px)")
const [isCommandPaletteOpen] = useAtom(commandPaletteOpenAtom)
const router = useRouter()
const personalTopics = useMemo(
() => [
...me.root.topicsWantToLearn.map(topic => ({ topic, learningState: "wantToLearn" as const })),
...me.root.topicsLearning.map(topic => ({ topic, learningState: "learning" as const })),
...me.root.topicsLearned.map(topic => ({ topic, learningState: "learned" as const }))
],
[me.root.topicsWantToLearn, me.root.topicsLearning, me.root.topicsLearned]
)
const itemCount = personalTopics.length
const handleEnter = useCallback(
(selectedTopic: Topic) => {
router.push(`/${selectedTopic.name}`)
},
[router]
)
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (isCommandPaletteOpen) return
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault()
setActiveItemIndex(prevIndex => {
if (prevIndex === null) return 0
const newIndex = e.key === "ArrowUp" ? (prevIndex - 1 + itemCount) % itemCount : (prevIndex + 1) % itemCount
return newIndex
})
} else if (e.key === "Enter" && !disableEnterKey && activeItemIndex !== null && personalTopics) {
e.preventDefault()
const selectedTopic = personalTopics[activeItemIndex]
if (selectedTopic?.topic) handleEnter?.(selectedTopic.topic)
}
},
[itemCount, isCommandPaletteOpen, activeItemIndex, setActiveItemIndex, disableEnterKey, personalTopics, handleEnter]
)
useEffect(() => {
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [handleKeyDown])
return (
<div className="flex h-full w-full flex-col overflow-hidden border-t">
{!isTablet && <ColumnHeader />}
<TopicListItems personalTopics={personalTopics} activeItemIndex={activeItemIndex} />
</div>
)
}
export const ColumnHeader: React.FC = () => {
const columnStyles = useColumnStyles()
return (
<div className="flex h-8 shrink-0 grow-0 flex-row gap-4 border-b max-lg:px-4 sm:px-6">
<Column.Wrapper style={columnStyles.title}>
<Column.Text>Name</Column.Text>
</Column.Wrapper>
<Column.Wrapper style={columnStyles.topic}>
<Column.Text>State</Column.Text>
</Column.Wrapper>
</div>
)
}
interface TopicListItemsProps {
personalTopics: PersonalTopic[] | null
activeItemIndex: number | null
}
const TopicListItems: React.FC<TopicListItemsProps> = ({ personalTopics, activeItemIndex }) => {
const setElementRef = useActiveItemScroll<HTMLDivElement>({ activeIndex: activeItemIndex })
return (
<Primitive.div
className="divide-primary/5 flex flex-1 flex-col divide-y overflow-y-auto outline-none [scrollbar-gutter:stable]"
tabIndex={-1}
role="list"
>
{personalTopics?.map(
(pt, index) =>
pt.topic?.id && (
<TopicItem
key={pt.topic.id}
ref={el => setElementRef(el, index)}
topic={pt.topic}
learningState={pt.learningState}
isActive={index === activeItemIndex}
/>
)
)}
</Primitive.div>
)
}

View File

@@ -0,0 +1,158 @@
import React, { useCallback, useMemo } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { useColumnStyles } from "../hooks/use-column-styles"
import { ListOfTopics, Topic } from "@/lib/schema"
import { Column } from "@/components/custom/column"
import { Button } from "@/components/ui/button"
import { LaIcon } from "@/components/custom/la-icon"
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
import { LearningStateSelectorContent } from "@/components/custom/learning-state-selector"
import { useAtom } from "jotai"
import { topicOpenPopoverForIdAtom } from "../list"
import { LEARNING_STATES, LearningStateValue } from "@/lib/constants"
import { useAccount } from "@/lib/providers/jazz-provider"
interface TopicItemProps {
topic: Topic
learningState: LearningStateValue
isActive: boolean
}
export const TopicItem = React.forwardRef<HTMLDivElement, TopicItemProps>(({ topic, learningState, isActive }, ref) => {
const columnStyles = useColumnStyles()
const [openPopoverForId, setOpenPopoverForId] = useAtom(topicOpenPopoverForIdAtom)
const { me } = useAccount({ root: { topicsWantToLearn: [], topicsLearning: [], topicsLearned: [] } })
let p: {
index: number
topic?: Topic | null
learningState: LearningStateValue
} | null = null
const wantToLearnIndex = me?.root.topicsWantToLearn.findIndex(t => t?.id === topic.id) ?? -1
if (wantToLearnIndex !== -1) {
p = {
index: wantToLearnIndex,
topic: me?.root.topicsWantToLearn[wantToLearnIndex],
learningState: "wantToLearn"
}
}
const learningIndex = me?.root.topicsLearning.findIndex(t => t?.id === topic.id) ?? -1
if (learningIndex !== -1) {
p = {
index: learningIndex,
topic: me?.root.topicsLearning[learningIndex],
learningState: "learning"
}
}
const learnedIndex = me?.root.topicsLearned.findIndex(t => t?.id === topic.id) ?? -1
if (learnedIndex !== -1) {
p = {
index: learnedIndex,
topic: me?.root.topicsLearned[learnedIndex],
learningState: "learned"
}
}
const selectedLearningState = useMemo(() => LEARNING_STATES.find(ls => ls.value === learningState), [learningState])
const handleLearningStateSelect = useCallback(
(value: string) => {
const newLearningState = value as LearningStateValue
const topicLists: Record<LearningStateValue, (ListOfTopics | null) | undefined> = {
wantToLearn: me?.root.topicsWantToLearn,
learning: me?.root.topicsLearning,
learned: me?.root.topicsLearned
}
const removeFromList = (state: LearningStateValue, index: number) => {
topicLists[state]?.splice(index, 1)
}
if (p) {
if (newLearningState === p.learningState) {
removeFromList(p.learningState, p.index)
return
}
removeFromList(p.learningState, p.index)
}
topicLists[newLearningState]?.push(topic)
setOpenPopoverForId(null)
},
[setOpenPopoverForId, me?.root.topicsWantToLearn, me?.root.topicsLearning, me?.root.topicsLearned, p, topic]
)
const handlePopoverTriggerClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setOpenPopoverForId(openPopoverForId === topic.id ? null : topic.id)
}
return (
<div
ref={ref}
className={cn("relative block", "min-h-12 py-2 max-lg:px-5 sm:px-6", {
"bg-muted-foreground/5": isActive,
"hover:bg-muted/50": !isActive
})}
role="listitem"
>
<Link
href={`/${topic.name}`}
className="flex h-full cursor-default items-center gap-4 outline-none"
tabIndex={isActive ? 0 : -1}
>
<Column.Wrapper style={columnStyles.title}>
<Column.Text className="truncate text-[13px] font-medium">{topic.prettyName}</Column.Text>
</Column.Wrapper>
<Column.Wrapper style={columnStyles.topic} className="max-sm:justify-end">
<Popover
open={openPopoverForId === topic.id}
onOpenChange={(open: boolean) => setOpenPopoverForId(open ? topic.id : null)}
>
<PopoverTrigger asChild>
<Button
size="sm"
type="button"
role="combobox"
variant="secondary"
className="size-7 shrink-0 p-0"
onClick={handlePopoverTriggerClick}
>
{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="end"
onClick={e => e.stopPropagation()}
onCloseAutoFocus={e => e.preventDefault()}
>
<LearningStateSelectorContent
showSearch={false}
searchPlaceholder="Search state..."
value={learningState}
onSelect={handleLearningStateSelect}
/>
</PopoverContent>
</Popover>
</Column.Wrapper>
</Link>
</div>
)
})
TopicItem.displayName = "TopicItem"

109
web/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
export const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm"
}
},
defaultVariants: {
side: "right"
}
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
)
)
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-foreground text-lg font-semibold", className)} {...props} />
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useRef, useCallback } from "react"
type ElementRef<T extends HTMLElement> = T | null
type ElementRefs<T extends HTMLElement> = ElementRef<T>[]
interface ActiveItemScrollOptions {
activeIndex: number | null
}
export function useActiveItemScroll<T extends HTMLElement>(options: ActiveItemScrollOptions) {
const { activeIndex } = options
const elementRefs = useRef<ElementRefs<T>>([])
const scrollActiveElementIntoView = useCallback((index: number) => {
const activeElement = elementRefs.current[index]
activeElement?.scrollIntoView({ block: "nearest" })
}, [])
useEffect(() => {
if (activeIndex !== null) {
scrollActiveElementIntoView(activeIndex)
}
}, [activeIndex, scrollActiveElementIntoView])
const setElementRef = useCallback((element: ElementRef<T>, index: number) => {
elementRefs.current[index] = element
}, [])
return setElementRef
}

View File

@@ -0,0 +1,50 @@
import { useAtom } from "jotai"
import { useEffect, useCallback } from "react"
import { keyboardDisableSourcesAtom } from "@/store/keydown-manager"
const allowedKeys = ["Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"]
export function useKeyboardManager(sourceId: string) {
const [disableSources, setDisableSources] = useAtom(keyboardDisableSourcesAtom)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (disableSources.has(sourceId)) {
if (allowedKeys.includes(event.key)) {
if (event.key === "Escape") {
setDisableSources(prev => {
const next = new Set(prev)
next.delete(sourceId)
return next
})
}
} else {
event.stopPropagation()
}
}
}
window.addEventListener("keydown", handleKeyDown, true)
return () => window.removeEventListener("keydown", handleKeyDown, true)
}, [disableSources, sourceId, setDisableSources])
const disableKeydown = useCallback(
(disable: boolean) => {
console.log(`${sourceId} disable:`, disable)
setDisableSources(prev => {
const next = new Set(prev)
if (disable) {
next.add(sourceId)
} else {
next.delete(sourceId)
}
return next
})
},
[setDisableSources, sourceId]
)
const isKeyboardDisabled = disableSources.has(sourceId)
return { disableKeydown, isKeyboardDisabled }
}

View File

@@ -0,0 +1,21 @@
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])
}

View File

@@ -1,15 +0,0 @@
import { useMemo } from "react"
import { useCoState } from "@/lib/providers/jazz-provider"
import { PublicGlobalGroup } from "@/lib/schema/master/public-group"
import { Account, AnonymousJazzAgent, ID } from "jazz-tools"
import { Link, Topic } from "@/lib/schema"
const GLOBAL_GROUP_ID = process.env.NEXT_PUBLIC_JAZZ_GLOBAL_GROUP as ID<PublicGlobalGroup>
export function useTopicData(topicName: string, me: Account | AnonymousJazzAgent | undefined) {
const topicID = useMemo(() => me && Topic.findUnique({ topicName }, GLOBAL_GROUP_ID, me), [topicName, me])
const topic = useCoState(Topic, topicID, { latestGlobalGuide: { sections: [{ links: [] }] } })
return { topic }
}

View File

@@ -55,11 +55,7 @@ export function getShortcutKey(key: string): ShortcutKeyResult {
} else if (lowercaseKey === "alt") {
return isMacOS() ? { symbol: "⌥", readable: "Option" } : { symbol: "Alt", readable: "Alt" }
} else if (lowercaseKey === "shift") {
return { symbol: "⇧", readable: "Shift" }
} else if (lowercaseKey === "control") {
return { symbol: "⌃", readable: "Control" }
} else if (lowercaseKey === "windows" && !isMacOS()) {
return { symbol: "Win", readable: "Windows" }
return isMacOS() ? { symbol: "⇧", readable: "Shift" } : { symbol: "Shift", readable: "Shift" }
} else {
return { symbol: key.toUpperCase(), readable: key }
}
@@ -68,21 +64,3 @@ export function getShortcutKey(key: string): ShortcutKeyResult {
export function getShortcutKeys(keys: string[]): ShortcutKeyResult[] {
return keys.map(key => getShortcutKey(key))
}
export function getSpecialShortcut(shortcutName: string): ShortcutKeyResult[] {
if (shortcutName === "expandToolbar") {
return isMacOS()
? [getShortcutKey("control"), getShortcutKey("mod"), getShortcutKey("n")]
: [getShortcutKey("mod"), getShortcutKey("windows"), getShortcutKey("n")]
}
return []
}
export function formatShortcut(shortcutKeys: ShortcutKeyResult[]): string {
return shortcutKeys.map(key => key.symbol).join("")
}
export function formatReadableShortcut(shortcutKeys: ShortcutKeyResult[]): string {
return shortcutKeys.map(key => key.readable).join(" + ")
}

View File

@@ -9,11 +9,11 @@
"test": "jest"
},
"dependencies": {
"@clerk/nextjs": "^5.4.1",
"@clerk/nextjs": "^5.6.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@hookform/resolvers": "^3.9.0",
"@nothing-but/force-graph": "^0.9.4",
"@nothing-but/force-graph": "^0.9.5",
"@nothing-but/utils": "^0.16.0",
"@omit/react-confirm-dialog": "^1.1.5",
"@omit/react-fancy-switch": "^0.1.3",
@@ -37,45 +37,45 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@sentry/nextjs": "^8.30.0",
"@tanstack/react-virtual": "^3.10.7",
"@tiptap/core": "^2.6.6",
"@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bold": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6",
"@tiptap/extension-code": "^2.6.6",
"@tiptap/extension-code-block-lowlight": "^2.6.6",
"@tiptap/extension-color": "^2.6.6",
"@tiptap/extension-document": "^2.6.6",
"@tiptap/extension-dropcursor": "^2.6.6",
"@tiptap/extension-focus": "^2.6.6",
"@tiptap/extension-gapcursor": "^2.6.6",
"@tiptap/extension-hard-break": "^2.6.6",
"@tiptap/extension-heading": "^2.6.6",
"@tiptap/extension-history": "^2.6.6",
"@tiptap/extension-horizontal-rule": "^2.6.6",
"@tiptap/extension-image": "^2.6.6",
"@tiptap/extension-italic": "^2.6.6",
"@tiptap/extension-link": "^2.6.6",
"@tiptap/extension-list-item": "^2.6.6",
"@tiptap/extension-ordered-list": "^2.6.6",
"@tiptap/extension-paragraph": "^2.6.6",
"@tiptap/extension-placeholder": "^2.6.6",
"@tiptap/extension-strike": "^2.6.6",
"@tiptap/extension-task-item": "^2.6.6",
"@tiptap/extension-task-list": "^2.6.6",
"@tiptap/extension-text": "^2.6.6",
"@tiptap/extension-typography": "^2.6.6",
"@tiptap/pm": "^2.6.6",
"@tiptap/react": "^2.6.6",
"@tiptap/starter-kit": "^2.6.6",
"@tiptap/suggestion": "^2.6.6",
"@tanstack/react-virtual": "^3.10.8",
"@tiptap/core": "^2.7.2",
"@tiptap/extension-blockquote": "^2.7.2",
"@tiptap/extension-bold": "^2.7.2",
"@tiptap/extension-bullet-list": "^2.7.2",
"@tiptap/extension-code": "^2.7.2",
"@tiptap/extension-code-block-lowlight": "^2.7.2",
"@tiptap/extension-color": "^2.7.2",
"@tiptap/extension-document": "^2.7.2",
"@tiptap/extension-dropcursor": "^2.7.2",
"@tiptap/extension-focus": "^2.7.2",
"@tiptap/extension-gapcursor": "^2.7.2",
"@tiptap/extension-hard-break": "^2.7.2",
"@tiptap/extension-heading": "^2.7.2",
"@tiptap/extension-history": "^2.7.2",
"@tiptap/extension-horizontal-rule": "^2.7.2",
"@tiptap/extension-image": "^2.7.2",
"@tiptap/extension-italic": "^2.7.2",
"@tiptap/extension-link": "^2.7.2",
"@tiptap/extension-list-item": "^2.7.2",
"@tiptap/extension-ordered-list": "^2.7.2",
"@tiptap/extension-paragraph": "^2.7.2",
"@tiptap/extension-placeholder": "^2.7.2",
"@tiptap/extension-strike": "^2.7.2",
"@tiptap/extension-task-item": "^2.7.2",
"@tiptap/extension-task-list": "^2.7.2",
"@tiptap/extension-text": "^2.7.2",
"@tiptap/extension-typography": "^2.7.2",
"@tiptap/pm": "^2.7.2",
"@tiptap/react": "^2.7.2",
"@tiptap/starter-kit": "^2.7.2",
"@tiptap/suggestion": "^2.7.2",
"axios": "^1.7.7",
"cheerio": "1.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.5.4",
"framer-motion": "^11.5.5",
"geist": "^1.3.1",
"jazz-browser-auth-clerk": "0.7.35-guest-auth.5",
"jazz-react": "0.7.35-guest-auth.5",
@@ -84,7 +84,7 @@
"jotai": "^2.9.3",
"lowlight": "^3.1.0",
"lucide-react": "^0.429.0",
"next": "14.2.5",
"next": "14.2.10",
"next-themes": "^0.3.0",
"nuqs": "^1.19.1",
"react": "^18.3.1",
@@ -99,27 +99,27 @@
"streaming-markdown": "^0.0.14",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.2",
"vaul": "^0.9.4",
"zod": "^3.23.8",
"zsa": "^0.6.0",
"zsa-react": "^0.2.2"
},
"devDependencies": {
"@ronin/learn-anything": "^0.0.0-3451954511456",
"@ronin/learn-anything": "^0.0.0-3452357373461",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.12",
"@types/node": "^22.5.4",
"@types/react": "^18.3.5",
"@types/jest": "^29.5.13",
"@types/node": "^22.5.5",
"@types/react": "^18.3.7",
"@types/react-dom": "^18.3.0",
"dotenv": "^16.4.5",
"eslint": "^8.57.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.5",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.45",
"postcss": "^8.4.47",
"prettier-plugin-tailwindcss": "^0.6.6",
"tailwindcss": "^3.4.10",
"tailwindcss": "^3.4.12",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai"
export const keyboardDisableSourcesAtom = atom<Set<string>>(new Set<string>())