mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Merge branch 'main' into tasks
This commit is contained in:
156
web/components/custom/Shortcut/shortcut.tsx
Normal file
156
web/components/custom/Shortcut/shortcut.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
web/components/custom/column.tsx
Normal file
42
web/components/custom/column.tsx
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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("__")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
23
web/components/custom/discordIcon.tsx
Normal file
23
web/components/custom/discordIcon.tsx
Normal 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>
|
||||
)
|
||||
63
web/components/custom/global-keydown-handler.tsx
Normal file
63
web/components/custom/global-keydown-handler.tsx
Normal 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
|
||||
}
|
||||
@@ -20,7 +20,7 @@ interface LearningStateSelectorProps {
|
||||
|
||||
export const LearningStateSelector: React.FC<LearningStateSelectorProps> = ({
|
||||
showSearch = true,
|
||||
defaultLabel = "Select state",
|
||||
defaultLabel = "State",
|
||||
searchPlaceholder = "Search state...",
|
||||
value,
|
||||
onChange,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user