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

@@ -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

@@ -0,0 +1,42 @@
import React from "react"
import { cn } from "@/lib/utils"
interface ColumnWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
style?: { [key: string]: string }
}
interface ColumnTextProps extends React.HTMLAttributes<HTMLSpanElement> {}
const ColumnWrapper = React.forwardRef<HTMLDivElement, ColumnWrapperProps>(
({ children, className, style, ...props }, ref) => (
<div
className={cn("flex grow flex-row items-center justify-start", className)}
style={{
width: "var(--width)",
minWidth: "var(--min-width, min-content)",
maxWidth: "min(var(--width), var(--max-width))",
flexBasis: "var(--width)",
...style
}}
ref={ref}
{...props}
>
{children}
</div>
)
)
ColumnWrapper.displayName = "ColumnWrapper"
const ColumnText = React.forwardRef<HTMLSpanElement, ColumnTextProps>(({ children, className, ...props }, ref) => (
<span className={cn("text-left text-xs", className)} ref={ref} {...props}>
{children}
</span>
))
ColumnText.displayName = "ColumnText"
export const Column = {
Wrapper: ColumnWrapper,
Text: ColumnText
}

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